mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-08 19:28:38 +01:00
Compare commits
5 commits
master
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a8a68a78 | ||
|
|
a1ff9f3f2b | ||
|
|
319651d7d1 | ||
|
|
593b5db8e9 | ||
|
|
19365e5ad0 |
1964 changed files with 59567 additions and 219295 deletions
|
|
@ -9,9 +9,12 @@ ARG INSTALL_NODE="true"
|
||||||
ARG NODE_VERSION="lts/*"
|
ARG NODE_VERSION="lts/*"
|
||||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||||
|
|
||||||
# Install additional OS packages
|
# [Optional] Uncomment this section to install additional OS packages.
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends ffmpeg
|
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
|
||||||
|
|
||||||
|
# [Optional] Uncomment the next line to use go get to install anything else you need
|
||||||
|
# RUN go get -x <your-dependency-or-tool>
|
||||||
|
|
||||||
# [Optional] Uncomment this line to install global node packages.
|
# [Optional] Uncomment this line to install global node packages.
|
||||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"dockerfile": "Dockerfile",
|
"dockerfile": "Dockerfile",
|
||||||
"args": {
|
"args": {
|
||||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||||
"VARIANT": "1.27",
|
"VARIANT": "1.25",
|
||||||
// Options
|
// Options
|
||||||
"INSTALL_NODE": "true",
|
"INSTALL_NODE": "true",
|
||||||
"NODE_VERSION": "v24"
|
"NODE_VERSION": "v24"
|
||||||
|
|
@ -54,6 +54,8 @@
|
||||||
4533,
|
4533,
|
||||||
4633
|
4633
|
||||||
],
|
],
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
// "postCreateCommand": "make setup-dev",
|
||||||
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "vscode",
|
"remoteUser": "vscode",
|
||||||
"remoteEnv": {
|
"remoteEnv": {
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,4 @@ dist
|
||||||
binaries
|
binaries
|
||||||
cache
|
cache
|
||||||
music
|
music
|
||||||
music.old
|
|
||||||
!Dockerfile
|
!Dockerfile
|
||||||
6
.github/FUNDING.yml
vendored
6
.github/FUNDING.yml
vendored
|
|
@ -1,10 +1,10 @@
|
||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
ko_fi: deluan
|
|
||||||
github: deluan
|
github: deluan
|
||||||
open_collective: navidrome
|
|
||||||
liberapay: deluan
|
|
||||||
patreon: # Replace with a single Patreon username
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: deluan
|
||||||
|
liberapay: deluan
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
|
|
||||||
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,74 +1,98 @@
|
||||||
name: Bug report
|
name: Bug Report
|
||||||
description: Report something that is not working as expected. You do not need to know the cause or how to fix it.
|
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["triage"]
|
labels: ["bug", "triage"]
|
||||||
#assignees:
|
#assignees:
|
||||||
# - deluan
|
# - deluan
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for reporting a problem. For a possible security vulnerability, use [private reporting](https://github.com/navidrome/navidrome/security/advisories/new).
|
### Thanks for taking the time to fill out this bug report!
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: requirements
|
id: requirements
|
||||||
attributes:
|
attributes:
|
||||||
label: "I confirm that:"
|
label: "I confirm that:"
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing [open and closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) and found no matching open report.
|
- label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered
|
||||||
required: true
|
|
||||||
- label: I'm using the latest Navidrome release and can reproduce the issue on it.
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: What happened
|
|
||||||
description: Describe what you observed and how it affected you. Include any error message you saw. You do not need to diagnose the cause or suggest code changes. If a closed issue matches, link it here.
|
|
||||||
validations:
|
|
||||||
required: true
|
required: true
|
||||||
|
- label: I'm using the latest version (your issue may have been fixed already)
|
||||||
|
required: false
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Navidrome version
|
label: Version
|
||||||
description: Which Navidrome version or build are you using? If you cannot find it, enter 'not sure'. If you tried another version, mention whether the behavior changed.
|
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected behavior and why
|
label: Current Behavior
|
||||||
description: What did you expect, and why? If available, link documentation, an applicable API contract, or a previously working version. A personal expectation is useful context too; it does not by itself establish that the product should change. If you are unsure, say so.
|
description: A concise description of what you're experiencing.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Expected Behavior
|
||||||
description: List the steps, relevant settings, and how often the problem occurs. If it happens only sometimes, describe what was happening when you noticed it and what you have already tried.
|
description: A concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps To Reproduce
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. In this scenario...
|
||||||
|
2. With this config...
|
||||||
|
3. Click (or Execute) '...'
|
||||||
|
4. See error...
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: env
|
id: env
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant environment
|
label: Environment
|
||||||
description: >-
|
description: |
|
||||||
Include only details relevant to this problem: server operating system, installation method, and the browser or client app with its version, if involved. Leave out details you do not know. Examples of installation methods: Docker, downloaded binary, package, or source build.
|
examples:
|
||||||
validations:
|
- **OS**: Ubuntu 20.04
|
||||||
required: false
|
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
||||||
- type: textarea
|
- **Client**: DSub 5.5.1
|
||||||
id: config-logs
|
value: |
|
||||||
|
- OS:
|
||||||
|
- Browser:
|
||||||
|
- Client:
|
||||||
|
render: markdown
|
||||||
|
- type: dropdown
|
||||||
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant configuration and logs
|
label: How Navidrome is installed?
|
||||||
description: >-
|
multiple: false
|
||||||
Paste only the configuration settings or log excerpt related to this problem. Remove passwords, tokens, API keys, session cookies, private URLs, and identifying information before submitting. Debug logs are optional. If you cannot safely share an excerpt, explain what it shows instead. [Log guidance](https://www.navidrome.org/docs/faq/#-where-are-the-logs).
|
options:
|
||||||
|
- Docker
|
||||||
|
- Binary (from downloads page)
|
||||||
|
- Package
|
||||||
|
- Built from sources
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional-context
|
id: config
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots or additional context
|
label: Configuration
|
||||||
description: Add screenshots, recordings, links, or other observations that help explain the problem. Remove private information from attachments as well.
|
description: Please copy and paste your `navidrome.toml` (and/or `docker-compose.yml`) configuration. This will be automatically formatted into code, so no need for backticks.
|
||||||
validations:
|
render: toml
|
||||||
required: false
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant log output
|
||||||
|
description: Please copy and paste any relevant log output (change your `LogLevel` (`ND_LOGLEVEL`) to debug). This will be automatically formatted into code, so no need for backticks. ([Where I can find the logs?](https://www.navidrome.org/docs/faq/#where-are-the-logs))
|
||||||
|
render: shell
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: |
|
||||||
|
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||||
|
|
||||||
|
Tip: You can attach screenshots by clicking this area to highlight it and then dragging files in.
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ideas and behavior changes
|
- name: Ideas for new features
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
||||||
about: Describe the problem, intended outcome, and use case before starting an implementation.
|
about: This is the place to share and discuss new ideas and potentially new features.
|
||||||
- name: Support requests
|
- name: Support requests
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
||||||
about: Ask for help with setup, configuration, or using Navidrome.
|
about: This is the place to ask questions.
|
||||||
|
|
|
||||||
23
.github/actions/download-taglib/action.yml
vendored
Normal file
23
.github/actions/download-taglib/action.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: 'Download TagLib'
|
||||||
|
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version of TagLib to download'
|
||||||
|
required: true
|
||||||
|
platform:
|
||||||
|
description: 'Platform to download TagLib for'
|
||||||
|
default: 'linux-amd64'
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Download TagLib
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/taglib
|
||||||
|
cd /tmp
|
||||||
|
FILE=taglib-${{ inputs.platform }}.tar.gz
|
||||||
|
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
|
||||||
|
tar -xzf ${FILE} -C taglib
|
||||||
|
PKG_CONFIG_PREFIX=/tmp/taglib
|
||||||
|
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
|
||||||
|
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
|
||||||
14
.github/actions/prepare-docker/action.yml
vendored
14
.github/actions/prepare-docker/action.yml
vendored
|
|
@ -53,13 +53,13 @@ runs:
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: inputs.hub_username != '' && inputs.hub_password != ''
|
if: inputs.hub_username != '' && inputs.hub_password != ''
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ inputs.hub_username }}
|
username: ${{ inputs.hub_username }}
|
||||||
password: ${{ inputs.hub_password }}
|
password: ${{ inputs.hub_password }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
@ -67,18 +67,12 @@ runs:
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
|
||||||
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
|
|
||||||
buildkitd-config-inline: |
|
|
||||||
[registry."docker.io"]
|
|
||||||
mirrors = ["mirror.gcr.io"]
|
|
||||||
|
|
||||||
- name: Extract metadata for Docker image
|
- name: Extract metadata for Docker image
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
github-token: ${{ inputs.github_token }}
|
|
||||||
labels: |
|
labels: |
|
||||||
maintainer=deluan@navidrome.org
|
maintainer=deluan@navidrome.org
|
||||||
images: |
|
images: |
|
||||||
|
|
|
||||||
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
|
|
@ -1,13 +1,8 @@
|
||||||
### Description
|
### Description
|
||||||
<!-- Please provide a clear and concise description of what this PR does and why it is needed. AI tools may help you draft, edit, or translate it, but review the final text and make sure you agree with it. Please be mindful of the maintainer's time. -->
|
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
|
||||||
|
|
||||||
**AI use** (check one):
|
|
||||||
- [ ] Yes, AI helped generate or modify code or tests, draft substantive PR text, or create this PR
|
|
||||||
- [ ] No
|
|
||||||
<!-- Tool names and prompts are not required. -->
|
|
||||||
|
|
||||||
### Related Issues
|
### Related Issues
|
||||||
<!-- List related issues, such as "Fixes #123" or "Related to #456". When prior agreement is required, link the maintainer's agreement on the intended behavior and scope. Otherwise, explain why agreement is not needed, such as the small-change exception or a translation update. For a larger change, link the agreed design and identify this phase. -->
|
<!-- List any related issues, e.g., "Fixes #123" or "Related to #456". -->
|
||||||
|
|
||||||
### Type of Change
|
### Type of Change
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
|
|
@ -17,29 +12,27 @@
|
||||||
- [ ] Other (please describe):
|
- [ ] Other (please describe):
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
Please review and check all that apply. Leave unverified items unchecked and explain them under How to Test.
|
Please review and check all that apply:
|
||||||
|
|
||||||
- [ ] My code follows the project’s coding style
|
- [ ] My code follows the project’s coding style
|
||||||
- [ ] I have tested the changes locally
|
- [ ] I have tested the changes locally
|
||||||
- [ ] I have added or updated documentation as needed
|
- [ ] I have added or updated documentation as needed
|
||||||
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
||||||
- [ ] All existing and new tests pass
|
- [ ] All existing and new tests pass
|
||||||
- [ ] I personally read and reviewed the entire diff and relevant code before opening this PR, and have done the same for every subsequent update, if any.
|
|
||||||
- [ ] For code changes, I have the development knowledge to understand, test, and maintain the implementation, assess its security and performance implications, and answer review questions.
|
|
||||||
|
|
||||||
### How to Test
|
### How to Test
|
||||||
<!-- Describe the steps to test your changes. Include setup, commands actually run, and results actually observed before and after the change. State how you personally checked the affected functionality. List failed or unrun checks, untested cases, and limitations. -->
|
<!-- Describe the steps to test your changes. Include setup, commands, and expected results. -->
|
||||||
|
|
||||||
### Screenshots / Demos (if applicable)
|
### Screenshots / Demos (if applicable)
|
||||||
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
||||||
|
|
||||||
### Additional Notes
|
### Additional Notes
|
||||||
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? Where relevant, explain important tradeoffs, new dependencies or ongoing maintenance costs, and simpler alternatives considered. -->
|
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
**Tips for Contributors:**
|
**Tips for Contributors:**
|
||||||
- Be concise but thorough.
|
- Be concise but thorough.
|
||||||
- If your PR is large, consider breaking it into smaller PRs within the agreed design and scope.
|
- If your PR is large, consider breaking it into smaller PRs.
|
||||||
- Review timing depends on maintainer availability; please do not tag the maintainer for faster review.
|
- Tag the maintainer if you need a prompt review.
|
||||||
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
|
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
|
||||||
-->
|
-->
|
||||||
60
.github/workflows/coverage-on-pr.yml
vendored
60
.github/workflows/coverage-on-pr.yml
vendored
|
|
@ -1,60 +0,0 @@
|
||||||
name: Report coverage on PR
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ['Pipeline: Test, Lint, Build']
|
|
||||||
types: [completed]
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
name: Comment coverage report
|
|
||||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
pull-requests: write
|
|
||||||
env:
|
|
||||||
COVERAGE_COMMENT: 'true'
|
|
||||||
steps:
|
|
||||||
# Only the config, from the base branch: this job holds a write token, so
|
|
||||||
# it must never check out the fork.
|
|
||||||
- name: Check out the octocov config
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
sparse-checkout: .octocov.yml
|
|
||||||
sparse-checkout-cone-mode: false
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Into a subdirectory. A pull_request run executes the fork's own copy of
|
|
||||||
# pipeline.yml, so every file in here is attacker-controlled.
|
|
||||||
- uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
name: octocov-pr
|
|
||||||
path: untrusted
|
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
|
||||||
github-token: ${{ github.token }}
|
|
||||||
|
|
||||||
- name: Verify the artifact and take the coverage profile
|
|
||||||
id: pr
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: |
|
|
||||||
number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]')
|
|
||||||
case "$number" in ''|*[!0-9]*)
|
|
||||||
echo "::error::artifact pr_number is not a number"; exit 1;;
|
|
||||||
esac
|
|
||||||
sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha)
|
|
||||||
if [ "$sha" != "$HEAD_SHA" ]; then
|
|
||||||
echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1
|
|
||||||
fi
|
|
||||||
cp untrusted/coverage.out coverage.out
|
|
||||||
echo "number=$number" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- uses: k1LoW/octocov-action@v1
|
|
||||||
env:
|
|
||||||
# A workflow_run job looks like a push to the default branch. Point
|
|
||||||
# octocov back at the pull request and at the run that produced it.
|
|
||||||
GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }}
|
|
||||||
OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge
|
|
||||||
OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }}
|
|
||||||
24
.github/workflows/download-link-on-pr.yml
vendored
24
.github/workflows/download-link-on-pr.yml
vendored
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v9
|
- uses: actions/github-script@v3
|
||||||
with:
|
with:
|
||||||
# This snippet is public-domain, taken from
|
# This snippet is public-domain, taken from
|
||||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||||
|
|
@ -19,7 +19,8 @@ jobs:
|
||||||
const pull_user_id = ${{github.event.sender.id}};
|
const pull_user_id = ${{github.event.sender.id}};
|
||||||
|
|
||||||
const issue_number = await (async () => {
|
const issue_number = await (async () => {
|
||||||
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
|
const pulls = await github.pulls.list({owner, repo});
|
||||||
|
for await (const {data} of github.paginate.iterator(pulls)) {
|
||||||
for (const pull of data) {
|
for (const pull of data) {
|
||||||
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
|
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
|
||||||
return pull.number;
|
return pull.number;
|
||||||
|
|
@ -33,24 +34,21 @@ jobs:
|
||||||
return core.error(`No matching pull request found`);
|
return core.error(`No matching pull request found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||||
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
|
if (!artifacts.length) {
|
||||||
if (!downloadable.length) {
|
|
||||||
return core.error(`No artifacts found`);
|
return core.error(`No artifacts found`);
|
||||||
}
|
}
|
||||||
const header = `Download the artifacts for this pull request:`;
|
let body = `Download the artifacts for this pull request:\n`;
|
||||||
let body = `${header}\n`;
|
for (const art of artifacts) {
|
||||||
for (const art of downloadable) {
|
|
||||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
|
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
|
||||||
// Match on the body too: octocov also comments as github-actions[bot].
|
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
|
||||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
|
|
||||||
if (existing_comment) {
|
if (existing_comment) {
|
||||||
core.info(`Updating comment ${existing_comment.id}`);
|
core.info(`Updating comment ${existing_comment.id}`);
|
||||||
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||||
} else {
|
} else {
|
||||||
core.info(`Creating a comment`);
|
core.info(`Creating a comment`);
|
||||||
await github.rest.issues.createComment({repo, owner, issue_number, body});
|
await github.issues.createComment({repo, owner, issue_number, body});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
377
.github/workflows/pipeline.yml
vendored
377
.github/workflows/pipeline.yml
vendored
|
|
@ -14,6 +14,7 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
CROSS_TAGLIB_VERSION: "2.1.1-1"
|
||||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -24,7 +25,7 @@ jobs:
|
||||||
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
||||||
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
@ -32,7 +33,7 @@ jobs:
|
||||||
- name: Show git version info
|
- name: Show git version info
|
||||||
run: |
|
run: |
|
||||||
echo "git describe (dirty): $(git describe --dirty --always --tags)"
|
echo "git describe (dirty): $(git describe --dirty --always --tags)"
|
||||||
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
|
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
|
||||||
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
|
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
|
||||||
echo "github_ref: $GITHUB_REF"
|
echo "github_ref: $GITHUB_REF"
|
||||||
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
|
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
|
||||||
|
|
@ -40,7 +41,7 @@ jobs:
|
||||||
- name: Determine git current SHA and latest tag
|
- name: Determine git current SHA and latest tag
|
||||||
id: git-version
|
id: git-version
|
||||||
run: |
|
run: |
|
||||||
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
|
||||||
if [ -n "$GIT_TAG" ]; then
|
if [ -n "$GIT_TAG" ]; then
|
||||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||||
GIT_TAG=${GIT_TAG}-SNAPSHOT
|
GIT_TAG=${GIT_TAG}-SNAPSHOT
|
||||||
|
|
@ -62,22 +63,17 @@ jobs:
|
||||||
name: Lint Go code
|
name: Lint Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- name: Download TagLib
|
||||||
|
uses: ./.github/actions/download-taglib
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
# Keep CI on the same version `make lint` installs, so a clean local run
|
|
||||||
# cannot turn red in CI just because a new golangci-lint was released.
|
|
||||||
- name: Resolve golangci-lint version
|
|
||||||
id: golangci-version
|
|
||||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
version: ${{ steps.golangci-version.outputs.version }}
|
version: latest
|
||||||
problem-matchers: true
|
problem-matchers: true
|
||||||
args: --timeout 2m
|
args: --timeout 2m
|
||||||
|
|
||||||
|
|
@ -92,234 +88,25 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Resolve OpenAPI tool versions
|
|
||||||
id: api-tools
|
|
||||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache OpenAPI tools
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: bin
|
|
||||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
|
||||||
|
|
||||||
- name: Lint OpenAPI spec
|
|
||||||
run: make api-lint
|
|
||||||
|
|
||||||
- name: Run go generate
|
|
||||||
run: |
|
|
||||||
make api-gen
|
|
||||||
go generate ./...
|
|
||||||
- name: Verify no changes from go generate
|
|
||||||
run: |
|
|
||||||
git status --porcelain
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo 'Generated code is out of date. Run "make gen" and commit the changes'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check for breaking OpenAPI changes
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
|
||||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
|
||||||
|
|
||||||
validate-migrations:
|
|
||||||
name: Validate DB migrations
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# PR-only gate is at step level: a job-level skip would propagate through
|
|
||||||
# the needs chain (actions/runner#491) and skip all release jobs on tag pushes.
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
# Refresh the base branch so the check compares against its CURRENT tip,
|
|
||||||
# not the (possibly stale) commit the PR was opened against.
|
|
||||||
- name: Fetch latest base branch
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}"
|
|
||||||
- name: Validate migration ordering and naming
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
env:
|
|
||||||
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
|
|
||||||
run: ./.github/workflows/validate-migrations.sh
|
|
||||||
|
|
||||||
go:
|
go:
|
||||||
name: Test Go code
|
name: Test Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- name: Download TagLib
|
||||||
|
uses: ./.github/actions/download-taglib
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
# Name must stay unique across the workflow: octocov matches step names
|
|
||||||
# by name across every job, and waits for each match to finish.
|
|
||||||
- name: Test with coverage
|
|
||||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
|
|
||||||
|
|
||||||
- name: Test ndpgen
|
|
||||||
run: |
|
|
||||||
cd plugins/cmd/ndpgen
|
|
||||||
go test -shuffle=on -v
|
|
||||||
go build -o ndpgen .
|
|
||||||
./ndpgen --help
|
|
||||||
|
|
||||||
- name: Upload coverage profile
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: octocov-go
|
|
||||||
path: coverage.out
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
go-plugins:
|
|
||||||
name: Test Go plugins
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
id: setup-go
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
# Without this, the suite recompiles every test plugin WASM module,
|
|
||||||
# which dominates its runtime under -race.
|
|
||||||
- name: Cache the WASM compilation cache
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: plugins/testdata/.wazero-cache
|
|
||||||
key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }}
|
|
||||||
restore-keys: wazero-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Test plugins
|
|
||||||
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
|
|
||||||
|
|
||||||
- name: Upload coverage profile
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: octocov-plugins
|
|
||||||
path: coverage.out
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
coverage:
|
|
||||||
name: Report coverage
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [go, go-plugins]
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
env:
|
|
||||||
COVERAGE_COMMENT: 'false'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
pattern: octocov-*
|
|
||||||
|
|
||||||
# Merge here rather than letting octocov do it: octocov reports statement
|
|
||||||
# coverage for a single profile, but switches to line counting for several.
|
|
||||||
- name: Merge coverage profiles
|
|
||||||
run: |
|
|
||||||
echo "mode: atomic" > coverage.out
|
|
||||||
awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \
|
|
||||||
octocov-*/coverage.out | sort >> coverage.out
|
|
||||||
|
|
||||||
- uses: k1LoW/octocov-action@v1
|
|
||||||
|
|
||||||
- name: Save the PR number for the comment workflow
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: echo "${{ github.event.pull_request.number }}" > pr_number
|
|
||||||
|
|
||||||
- name: Upload the merged profile for the comment workflow
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: octocov-pr
|
|
||||||
path: |
|
|
||||||
coverage.out
|
|
||||||
pr_number
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
go-windows:
|
|
||||||
name: Test Go code (Windows)
|
|
||||||
runs-on: windows-2022
|
|
||||||
env:
|
|
||||||
FFMPEG_VERSION: "7.1"
|
|
||||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- uses: msys2/setup-msys2@v2
|
|
||||||
with:
|
|
||||||
msystem: MINGW64
|
|
||||||
install: mingw-w64-x86_64-gcc
|
|
||||||
update: false
|
|
||||||
|
|
||||||
- name: Add mingw64 to PATH
|
|
||||||
shell: bash
|
|
||||||
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Cache ffmpeg
|
|
||||||
id: ffmpeg-cache
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: C:\ffmpeg
|
|
||||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
|
||||||
|
|
||||||
- name: Download ffmpeg
|
|
||||||
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
|
|
||||||
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
|
|
||||||
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
|
|
||||||
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
|
|
||||||
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
|
|
||||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
|
|
||||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
|
|
||||||
|
|
||||||
- name: Add ffmpeg to PATH
|
|
||||||
shell: bash
|
|
||||||
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Verify toolchain
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
go version
|
|
||||||
where.exe gcc
|
|
||||||
gcc --version
|
|
||||||
ffmpeg -version
|
|
||||||
ffprobe -version
|
|
||||||
|
|
||||||
- name: Download dependencies
|
|
||||||
shell: bash
|
|
||||||
run: go mod download
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: "1"
|
|
||||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
|
||||||
|
|
||||||
- name: Test ndpgen
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
cd plugins/cmd/ndpgen
|
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||||
go test -shuffle=on -v
|
go test -shuffle=on -tags netgo -race ./... -v
|
||||||
go build -o ndpgen.exe .
|
|
||||||
./ndpgen.exe --help
|
|
||||||
|
|
||||||
js:
|
js:
|
||||||
name: Test JS code
|
name: Test JS code
|
||||||
|
|
@ -327,7 +114,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
@ -358,7 +145,7 @@ jobs:
|
||||||
name: Lint i18n files
|
name: Lint i18n files
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
- run: |
|
- run: |
|
||||||
set -e
|
set -e
|
||||||
for file in resources/i18n/*.json; do
|
for file in resources/i18n/*.json; do
|
||||||
|
|
@ -385,10 +172,10 @@ jobs:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ 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 ]
|
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
||||||
|
|
@ -404,7 +191,7 @@ jobs:
|
||||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Prepare Docker Buildx
|
- name: Prepare Docker Buildx
|
||||||
uses: ./.github/actions/prepare-docker
|
uses: ./.github/actions/prepare-docker
|
||||||
|
|
@ -416,7 +203,7 @@ jobs:
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Build Binaries
|
- name: Build Binaries
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
@ -427,24 +214,10 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
- name: Set up QEMU for smoke test
|
|
||||||
if: env.IS_LINUX == 'true'
|
|
||||||
uses: docker/setup-qemu-action@v4
|
|
||||||
|
|
||||||
# The binary is static, so binfmt+qemu runs it directly on the runner.
|
|
||||||
# Catches startup crashes in cross-compiled binaries before they ship,
|
|
||||||
# e.g. the broken ifunc relocations on 32-bit arm from issue #5738.
|
|
||||||
- name: Smoke-test binary
|
|
||||||
if: env.IS_LINUX == 'true'
|
|
||||||
run: |
|
|
||||||
BIN=./output/${{ env.PLATFORM }}/navidrome
|
|
||||||
chmod +x "$BIN"
|
|
||||||
"$BIN" --help >/dev/null
|
|
||||||
echo "OK: ${{ matrix.platform }} binary starts"
|
|
||||||
|
|
||||||
- name: Upload Binaries
|
- name: Upload Binaries
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome-${{ env.PLATFORM }}
|
name: navidrome-${{ env.PLATFORM }}
|
||||||
path: ./output
|
path: ./output
|
||||||
|
|
@ -453,7 +226,7 @@ jobs:
|
||||||
- name: Build and push image by digest
|
- name: Build and push image by digest
|
||||||
id: push-image
|
id: push-image
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
@ -462,6 +235,7 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
outputs: |
|
outputs: |
|
||||||
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
|
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
|
||||||
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
@ -474,7 +248,7 @@ jobs:
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM }}
|
name: digests-${{ env.PLATFORM }}
|
||||||
|
|
@ -482,21 +256,18 @@ jobs:
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
push-manifest-ghcr:
|
push-manifest:
|
||||||
name: Push to GHCR
|
name: Push Docker manifest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build, check-push-enabled]
|
needs: [build, check-push-enabled]
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true'
|
if: needs.check-push-enabled.outputs.is_enabled == 'true'
|
||||||
env:
|
env:
|
||||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
@ -507,75 +278,37 @@ jobs:
|
||||||
id: docker
|
id: docker
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
||||||
|
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Create manifest list and push to ghcr.io
|
- name: Create manifest list and push to ghcr.io
|
||||||
working-directory: /tmp/digests
|
working-directory: /tmp/digests
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Create manifest list and push to Docker Hub
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
if: vars.DOCKER_HUB_REPO != ''
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
|
||||||
|
|
||||||
- name: Inspect image in ghcr.io
|
- name: Inspect image in ghcr.io
|
||||||
run: |
|
run: |
|
||||||
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:
|
|
||||||
name: Push to Docker Hub (${{ matrix.repository }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
needs: [build, check-push-enabled]
|
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
|
||||||
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:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Prepare Docker Buildx
|
|
||||||
uses: ./.github/actions/prepare-docker
|
|
||||||
id: docker
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
hub_repository: ${{ matrix.repository }}
|
|
||||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Create manifest list and push to Docker Hub
|
|
||||||
uses: nick-fields/retry@v4
|
|
||||||
with:
|
|
||||||
timeout_minutes: 5
|
|
||||||
max_attempts: 3
|
|
||||||
retry_wait_seconds: 30
|
|
||||||
command: |
|
|
||||||
cd /tmp/digests
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: Inspect image in Docker Hub
|
- name: Inspect image in Docker Hub
|
||||||
|
if: vars.DOCKER_HUB_REPO != ''
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
cleanup-digests:
|
|
||||||
name: Cleanup digest artifacts
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [push-manifest-ghcr, push-manifest-dockerhub]
|
|
||||||
if: always() && needs.push-manifest-ghcr.result == 'success'
|
|
||||||
steps:
|
|
||||||
- name: Delete unnecessary digest artifacts
|
- name: Delete unnecessary digest artifacts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -585,9 +318,9 @@ jobs:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-windows*
|
pattern: navidrome-windows*
|
||||||
|
|
@ -606,7 +339,7 @@ jobs:
|
||||||
du -h binaries/msi/*.msi
|
du -h binaries/msi/*.msi
|
||||||
|
|
||||||
- name: Upload MSI files
|
- name: Upload MSI files
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome-windows-installers
|
name: navidrome-windows-installers
|
||||||
path: binaries/msi/*.msi
|
path: binaries/msi/*.msi
|
||||||
|
|
@ -619,12 +352,12 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-*
|
pattern: navidrome-*
|
||||||
|
|
@ -637,9 +370,9 @@ jobs:
|
||||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: '2.16.0'
|
version: '~> v2'
|
||||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
@ -650,7 +383,7 @@ jobs:
|
||||||
rm ./dist/*.tar.gz ./dist/*.zip
|
rm ./dist/*.tar.gz ./dist/*.zip
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist/navidrome_0*
|
path: dist/navidrome_0*
|
||||||
|
|
@ -673,13 +406,13 @@ jobs:
|
||||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all-packages artifact
|
- name: Download all-packages artifact
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome_linux_${{ matrix.item }}
|
name: navidrome_linux_${{ matrix.item }}
|
||||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||||
|
|
|
||||||
138
.github/workflows/push-translations.sh
vendored
138
.github/workflows/push-translations.sh
vendored
|
|
@ -1,138 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
I18N_DIR=resources/i18n
|
|
||||||
|
|
||||||
# Normalize JSON for deterministic comparison:
|
|
||||||
# remove empty/null attributes, sort keys alphabetically
|
|
||||||
process_json() {
|
|
||||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get list of all languages configured in the POEditor project
|
|
||||||
get_language_list() {
|
|
||||||
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
|
||||||
-d api_token="${POEDITOR_APIKEY}" \
|
|
||||||
-d id="${POEDITOR_PROJECTID}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract language name from the language list JSON given a language code
|
|
||||||
get_language_name() {
|
|
||||||
lang_code="$1"
|
|
||||||
lang_list="$2"
|
|
||||||
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
|
||||||
get_lang_code() {
|
|
||||||
filepath="$1"
|
|
||||||
filename=$(basename "$filepath")
|
|
||||||
echo "${filename%.*}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Export the current translation for a language from POEditor (v2 API)
|
|
||||||
export_language() {
|
|
||||||
lang_code="$1"
|
|
||||||
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
|
||||||
-d api_token="${POEDITOR_APIKEY}" \
|
|
||||||
-d id="${POEDITOR_PROJECTID}" \
|
|
||||||
-d language="$lang_code" \
|
|
||||||
-d type="key_value_json")
|
|
||||||
|
|
||||||
url=$(echo "$response" | jq -r '.result.url')
|
|
||||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
|
||||||
echo "Failed to export $lang_code: $response" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "$url"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Flatten nested JSON to POEditor languages/update format.
|
|
||||||
# POEditor uses term + context pairs, where:
|
|
||||||
# term = the leaf key name
|
|
||||||
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
|
||||||
flatten_to_poeditor() {
|
|
||||||
jq -c '[paths(scalars) as $p |
|
|
||||||
{
|
|
||||||
"term": ($p | last | tostring),
|
|
||||||
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
|
||||||
"translation": {"content": getpath($p)}
|
|
||||||
}
|
|
||||||
]' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update translations for a language in POEditor via languages/update API
|
|
||||||
update_language() {
|
|
||||||
lang_code="$1"
|
|
||||||
file="$2"
|
|
||||||
|
|
||||||
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
|
||||||
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
|
||||||
-d api_token="${POEDITOR_APIKEY}" \
|
|
||||||
-d id="${POEDITOR_PROJECTID}" \
|
|
||||||
-d language="$lang_code" \
|
|
||||||
--data-urlencode data@/tmp/poeditor_data.json)
|
|
||||||
rm -f /tmp/poeditor_data.json
|
|
||||||
|
|
||||||
status=$(echo "$response" | jq -r '.response.status')
|
|
||||||
if [ "$status" != "success" ]; then
|
|
||||||
echo "Failed to update $lang_code: $response" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
|
||||||
added=$(echo "$response" | jq -r '.result.translations.added')
|
|
||||||
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
|
||||||
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Main ---
|
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
|
||||||
echo "Usage: $0 <file1> [file2] ..."
|
|
||||||
echo "No files specified. Nothing to do."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
lang_list=$(get_language_list)
|
|
||||||
upload_count=0
|
|
||||||
|
|
||||||
for file in "$@"; do
|
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
echo "Warning: File not found: $file, skipping"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
lang_code=$(get_lang_code "$file")
|
|
||||||
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
|
||||||
|
|
||||||
if [ -z "$lang_name" ]; then
|
|
||||||
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Processing $lang_name ($lang_code)..."
|
|
||||||
|
|
||||||
# Export current state from POEditor
|
|
||||||
url=$(export_language "$lang_code")
|
|
||||||
curl -sSL "$url" -o poeditor_export.json
|
|
||||||
|
|
||||||
# Normalize both files for comparison
|
|
||||||
process_json "$file" > local_normalized.json
|
|
||||||
process_json poeditor_export.json > remote_normalized.json
|
|
||||||
|
|
||||||
# Compare normalized versions
|
|
||||||
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
|
||||||
echo " No differences, skipping"
|
|
||||||
else
|
|
||||||
echo " Differences found, updating POEditor..."
|
|
||||||
update_language "$lang_code" "$file"
|
|
||||||
upload_count=$((upload_count + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done. Updated $upload_count translation(s) in POEditor."
|
|
||||||
32
.github/workflows/push-translations.yml
vendored
32
.github/workflows/push-translations.yml
vendored
|
|
@ -1,32 +0,0 @@
|
||||||
name: POEditor export
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths:
|
|
||||||
- 'resources/i18n/*.json'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
push-translations:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.repository_owner == 'navidrome' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Detect changed translation files
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
|
|
||||||
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
|
|
||||||
echo "Changed translation files: $CHANGED_FILES"
|
|
||||||
|
|
||||||
- name: Push translations to POEditor
|
|
||||||
if: ${{ steps.changed.outputs.files != '' }}
|
|
||||||
env:
|
|
||||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
|
||||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
|
||||||
run: |
|
|
||||||
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
|
|
||||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v6
|
- uses: dessant/lock-threads@v5
|
||||||
with:
|
with:
|
||||||
process-only: 'issues, prs'
|
process-only: 'issues, prs'
|
||||||
issue-inactive-days: 120
|
issue-inactive-days: 120
|
||||||
|
|
@ -28,7 +28,7 @@ jobs:
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new issue for related bugs.
|
Please open a new issue for related bugs.
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
operations-per-run: 999
|
operations-per-run: 999
|
||||||
days-before-issue-stale: 180
|
days-before-issue-stale: 180
|
||||||
|
|
|
||||||
4
.github/workflows/update-translations.yml
vendored
4
.github/workflows/update-translations.yml
vendored
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.repository_owner == 'navidrome' }}
|
if: ${{ github.repository_owner == 'navidrome' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
- name: Get updated translations
|
- name: Get updated translations
|
||||||
id: poeditor
|
id: poeditor
|
||||||
env:
|
env:
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
git diff
|
git diff
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v8
|
uses: peter-evans/create-pull-request@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PAT }}
|
token: ${{ secrets.PAT }}
|
||||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||||
|
|
|
||||||
150
.github/workflows/validate-migrations.sh
vendored
150
.github/workflows/validate-migrations.sh
vendored
|
|
@ -1,150 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Validates DB migrations added by a pull request:
|
|
||||||
# 1. Ordering - an added migration must be NEWER than the latest migration
|
|
||||||
# already on the base branch. Goose applies migrations in
|
|
||||||
# timestamp order, so an older-timestamped migration would be
|
|
||||||
# silently skipped on databases already upgraded past it.
|
|
||||||
# 2. Uniqueness - no two migration files may share a timestamp.
|
|
||||||
# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go).
|
|
||||||
#
|
|
||||||
# On failure it prints a human-readable message and, when running in GitHub
|
|
||||||
# Actions, emits an error annotation bound to the offending file so the message
|
|
||||||
# also renders inline in the PR "Files changed" tab.
|
|
||||||
#
|
|
||||||
# Compares HEAD against $BASE_REF (default origin/master). Requires full history
|
|
||||||
# (fetch-depth: 0 in CI).
|
|
||||||
# -e is intentionally omitted: the script accumulates violations into $status
|
|
||||||
# and must not exit on the first non-zero command (grep no-match, a false [[ ]]
|
|
||||||
# in an if, `is_migration || continue`).
|
|
||||||
set -uo pipefail
|
|
||||||
export LC_ALL=C
|
|
||||||
|
|
||||||
MIGRATIONS_DIR="db/migrations"
|
|
||||||
BASE_REF="${BASE_REF:-origin/master}"
|
|
||||||
NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$'
|
|
||||||
|
|
||||||
status=0
|
|
||||||
|
|
||||||
# Log a message to stderr and mark the run as failed.
|
|
||||||
fail() {
|
|
||||||
printf '%s\n' "$1" >&2
|
|
||||||
status=1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Emit a GitHub Actions error annotation bound to a file, so the message renders
|
|
||||||
# inline on the offending migration in the PR "Files changed" tab. No-op outside
|
|
||||||
# CI. `%`, newline and CR are encoded as required by the workflow-command syntax
|
|
||||||
# (the `%` replacement must run first so the encodings we add aren't re-escaped).
|
|
||||||
annotate() { # $1=file $2=message
|
|
||||||
[ "${GITHUB_ACTIONS:-}" = "true" ] || return 0
|
|
||||||
local msg="$2"
|
|
||||||
msg="${msg//'%'/%25}"
|
|
||||||
msg="${msg//$'\n'/%0A}"
|
|
||||||
msg="${msg//$'\r'/%0D}"
|
|
||||||
printf '::error file=%s,line=1::%s\n' "$1" "$msg"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Report a migration problem: log it, annotate the offending file, mark failed.
|
|
||||||
report() { # $1=file $2=message
|
|
||||||
fail "$2"
|
|
||||||
printf '\n' >&2
|
|
||||||
annotate "$1" "$2"
|
|
||||||
}
|
|
||||||
|
|
||||||
human_ts() {
|
|
||||||
local t="$1"
|
|
||||||
printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix
|
|
||||||
local b="$1"
|
|
||||||
case "$b" in
|
|
||||||
*.sql | *.go) ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
[[ "${b%%_*}" =~ ^[0-9]{14}$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then
|
|
||||||
printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Newest timestamp already on the base branch ---
|
|
||||||
base_max=""
|
|
||||||
base_max_file=""
|
|
||||||
while IFS= read -r f; do
|
|
||||||
[ -z "$f" ] && continue
|
|
||||||
b="$(basename "$f")"
|
|
||||||
is_migration "$b" || continue
|
|
||||||
ts="${b%%_*}"
|
|
||||||
if [[ "$ts" > "$base_max" ]]; then
|
|
||||||
base_max="$ts"
|
|
||||||
base_max_file="$f"
|
|
||||||
fi
|
|
||||||
done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null)
|
|
||||||
|
|
||||||
# --- Ordering + naming on files added by this PR ---
|
|
||||||
while IFS= read -r f; do
|
|
||||||
[ -z "$f" ] && continue
|
|
||||||
b="$(basename "$f")"
|
|
||||||
case "$b" in
|
|
||||||
*.sql) ;; # any .sql in this dir must be a migration
|
|
||||||
*.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip
|
|
||||||
*) continue ;;
|
|
||||||
esac
|
|
||||||
if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then
|
|
||||||
report "$f" "❌ Migration file in a subdirectory: $f
|
|
||||||
Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and
|
|
||||||
top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED.
|
|
||||||
Move it to $MIGRATIONS_DIR/$b."
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if ! [[ "$b" =~ $NAME_RE ]]; then
|
|
||||||
report "$f" "❌ Malformed migration filename: $f
|
|
||||||
Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase.
|
|
||||||
Regenerate with: make migration-sql name=<description> (or make migration-go name=<description>)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
ts="${b%%_*}"
|
|
||||||
if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then
|
|
||||||
report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts"))
|
|
||||||
is older than (or equal to) the newest migration already on ${BASE_REF#origin/}:
|
|
||||||
$base_max_file ($(human_ts "$base_max"))
|
|
||||||
|
|
||||||
Goose applies migrations in timestamp order, so databases already upgraded
|
|
||||||
past that point would SILENTLY SKIP your migration.
|
|
||||||
|
|
||||||
Fix: regenerate it with a current timestamp:
|
|
||||||
make migration-sql name=<description> (or make migration-go name=<description>)
|
|
||||||
then move your SQL/Go body into the new file and delete the old one."
|
|
||||||
fi
|
|
||||||
done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)
|
|
||||||
|
|
||||||
# --- Duplicate timestamps across the merged set (HEAD) ---
|
|
||||||
all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)"
|
|
||||||
dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do
|
|
||||||
b="$(basename "$f")"
|
|
||||||
is_migration "$b" || continue
|
|
||||||
printf '%s\n' "${b%%_*}"
|
|
||||||
done | sort | uniq -d)"
|
|
||||||
if [ -n "$dups" ]; then
|
|
||||||
while IFS= read -r ts; do
|
|
||||||
[ -z "$ts" ] && continue
|
|
||||||
colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)"
|
|
||||||
printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2
|
|
||||||
while IFS= read -r cf; do
|
|
||||||
[ -z "$cf" ] && continue
|
|
||||||
printf ' %s\n' "$cf" >&2
|
|
||||||
annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*."
|
|
||||||
done <<< "$colliding"
|
|
||||||
printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2
|
|
||||||
status=1
|
|
||||||
done <<< "$dups"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$status" -eq 0 ]; then
|
|
||||||
echo "✅ DB migrations OK (ordering, uniqueness, naming)."
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -17,17 +17,14 @@ master.zip
|
||||||
testDB
|
testDB
|
||||||
cache/*
|
cache/*
|
||||||
*.swp
|
*.swp
|
||||||
coverage.out
|
|
||||||
dist
|
dist
|
||||||
music
|
music
|
||||||
music.old
|
|
||||||
*.db*
|
*.db*
|
||||||
.gitinfo
|
.gitinfo
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
!contrib/docker-compose.yml
|
!contrib/docker-compose.yml
|
||||||
binaries
|
binaries
|
||||||
navidrome-*
|
navidrome-*
|
||||||
/ndpgen
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.github/prompts
|
.github/prompts
|
||||||
.github/instructions
|
.github/instructions
|
||||||
|
|
@ -35,16 +32,3 @@ AGENTS.md
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.wasm
|
*.wasm
|
||||||
*.ndp
|
|
||||||
openspec/
|
|
||||||
.agents
|
|
||||||
go.work*
|
|
||||||
.worktrees/
|
|
||||||
.playwright-mcp/
|
|
||||||
|
|
||||||
# Temp benchmark files
|
|
||||||
zz_*_test.go
|
|
||||||
|
|
||||||
# wazero compilation cache for the plugins test suite
|
|
||||||
/plugins/testdata/.wazero-cache/
|
|
||||||
/plugins/testdata/*.stage/
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,16 @@ version: "2"
|
||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
- netgo
|
- netgo
|
||||||
- sqlite_fts5
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
- errorlint
|
- errorlint
|
||||||
- forbidigo
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
|
|
@ -28,9 +25,6 @@ linters:
|
||||||
disable:
|
disable:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
settings:
|
settings:
|
||||||
errcheck:
|
|
||||||
exclude-functions:
|
|
||||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
|
||||||
gocritic:
|
gocritic:
|
||||||
disable-all: true
|
disable-all: true
|
||||||
enabled-checks:
|
enabled-checks:
|
||||||
|
|
@ -41,29 +35,10 @@ linters:
|
||||||
- G401
|
- G401
|
||||||
- G505
|
- G505
|
||||||
- G115
|
- G115
|
||||||
forbidigo:
|
|
||||||
forbid:
|
|
||||||
- pattern: 'tx\.Exec$'
|
|
||||||
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
|
|
||||||
- pattern: 'tx\.Query$'
|
|
||||||
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
|
|
||||||
- pattern: 'tx\.QueryRow$'
|
|
||||||
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
|
|
||||||
govet:
|
govet:
|
||||||
enable:
|
enable:
|
||||||
- nilness
|
- nilness
|
||||||
exclusions:
|
exclusions:
|
||||||
rules:
|
|
||||||
- linters:
|
|
||||||
- gosec
|
|
||||||
path: _test\.go
|
|
||||||
text: "G703"
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- containedctx
|
|
||||||
- path-except: 'db/migrations/'
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
|
|
@ -74,9 +49,6 @@ linters:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules
|
|
||||||
- _gen\.go$
|
|
||||||
- .worktrees
|
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
|
|
@ -84,4 +56,3 @@ formatters:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules
|
|
||||||
|
|
|
||||||
44
.octocov.yml
44
.octocov.yml
|
|
@ -1,44 +0,0 @@
|
||||||
# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov
|
|
||||||
# The 30s default is not enough: scanning this repo's artifacts for the baseline
|
|
||||||
# eats most of it, leaving none for the report upload.
|
|
||||||
timeout: 5m
|
|
||||||
coverage:
|
|
||||||
# A single pre-merged profile: octocov reports statements for one path, but
|
|
||||||
# switches to line counting when it merges several itself.
|
|
||||||
paths:
|
|
||||||
- coverage.out
|
|
||||||
# Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated.
|
|
||||||
# Both patterns need the '**/' prefix: the comment workflow has no source tree,
|
|
||||||
# so octocov cannot shorten the profile's import paths to repo-relative ones.
|
|
||||||
exclude:
|
|
||||||
- '**/tests/**'
|
|
||||||
- '**/*_gen.go'
|
|
||||||
codeToTestRatio:
|
|
||||||
# Needs the pull request's own source, which the comment workflow must not
|
|
||||||
# check out: it holds a write token.
|
|
||||||
if: env.COVERAGE_COMMENT != 'true'
|
|
||||||
code:
|
|
||||||
- '**/*.go'
|
|
||||||
- '!**/*_test.go'
|
|
||||||
- '!**/*_gen.go'
|
|
||||||
test:
|
|
||||||
- '**/*_test.go'
|
|
||||||
testExecutionTime:
|
|
||||||
if: true
|
|
||||||
steps:
|
|
||||||
- Test with coverage
|
|
||||||
- Test plugins
|
|
||||||
diff:
|
|
||||||
datastores:
|
|
||||||
- artifact://${GITHUB_REPOSITORY}
|
|
||||||
comment:
|
|
||||||
# Only the 'Report coverage on PR' workflow sets this: a pull_request run from
|
|
||||||
# a fork gets a read-only token, so commenting from here 403s.
|
|
||||||
if: env.COVERAGE_COMMENT == 'true'
|
|
||||||
updatePrevious: true
|
|
||||||
summary:
|
|
||||||
if: true
|
|
||||||
report:
|
|
||||||
if: is_default_branch
|
|
||||||
datastores:
|
|
||||||
- artifact://${GITHUB_REPOSITORY}
|
|
||||||
111
CONTRIBUTING.md
111
CONTRIBUTING.md
|
|
@ -1,66 +1,93 @@
|
||||||
# Navidrome Contribution Guide
|
# Navidrome Contribution Guide
|
||||||
|
|
||||||
Navidrome has one maintainer and limited review capacity. We welcome contributions that fit the project and remain practical to maintain. A smaller number of focused, well-understood contributions is more useful than a large volume of code that needs extensive rework.
|
Navidrome is a streaming service which allows you to enjoy your music collection from anywhere. We'd welcome you to contribute to our open source project and make Navidrome even better. There are some basic guidelines which you need to follow if you like to contribute to Navidrome.
|
||||||
|
|
||||||
## Support and conduct
|
- [Asking Support Questions](#asking-support-questions)
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [Issues](#issues)
|
||||||
|
- [Pull Requests](#pull-requests)
|
||||||
|
|
||||||
Use [Discussions → Q&A](https://github.com/navidrome/navidrome/discussions/categories/q-a) for support. Please follow the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
||||||
|
|
||||||
## Issues and ideas
|
## Asking Support Questions
|
||||||
|
We have an active [discussion forum](https://github.com/navidrome/navidrome/discussions) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions.
|
||||||
|
|
||||||
Anyone may report a suspected bug. Search existing [open and closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) and [discussions](https://github.com/navidrome/navidrome/discussions), and check the [documentation](https://www.navidrome.org/docs/) and [FAQ](https://www.navidrome.org/docs/faq/) first. Use the [bug report form](https://github.com/navidrome/navidrome/issues/new?template=bug_report.yml) for suspected defects and [Discussions → Ideas](https://github.com/navidrome/navidrome/discussions/categories/ideas) for new features or changes to intended behavior.
|
## Code of Conduct
|
||||||
|
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
Describe what happened, what you expected, and why that expectation should hold. Include reproduction steps where possible, with a reference to documentation, an applicable API contract, or a previously working version. If the problem is intermittent, explain what you observed and tried. Remove credentials and private information from logs and configuration.
|
## Issues
|
||||||
|
Found any issue or bug in our codebase? Have a great idea you want to propose or discuss with
|
||||||
|
the developers? You can help by submitting an [issue](https://github.com/navidrome/navidrome/issues/new/choose)
|
||||||
|
to the GitHub repository.
|
||||||
|
|
||||||
A reported problem may involve intended behavior, configuration, or a product decision. An open issue or a label does not establish that a change has been accepted. Report security vulnerabilities [privately through GitHub](https://github.com/navidrome/navidrome/security/advisories/new).
|
**Before opening a new issue, please check if the issue has not been already made by searching
|
||||||
|
the [issues](https://github.com/navidrome/navidrome/issues)**
|
||||||
|
|
||||||
## Agree on the change before implementation
|
## Pull requests
|
||||||
|
Before submitting a pull request, ensure that you go through the following:
|
||||||
|
- Open a corresponding issue for the Pull Request, if not existing. The issue can be opened following [these guidelines](#issues)
|
||||||
|
- Ensure that there is no open or closed Pull Request corresponding to your submission to avoid duplication of effort.
|
||||||
|
- Setup the [development environment](https://www.navidrome.org/docs/developers/dev-environment/)
|
||||||
|
- Create a new branch on your forked repo and make the changes in it. Naming conventions for branch are: `<Issue Title>/<Issue Number>`. Example:
|
||||||
|
```
|
||||||
|
git checkout -b adding-docs/834 master
|
||||||
|
```
|
||||||
|
- The commits should follow a [specific convention](#commit-conventions)
|
||||||
|
- Ensure that a DCO sign-off for commits is provided via `--signoff` option of git commit
|
||||||
|
- Provide a link to the issue that will be closed via your Pull request.
|
||||||
|
|
||||||
For code contributions beyond the small exceptions below, explain the problem, intended behavior, and smallest proposed scope in an existing issue or discussion. Wait for an explicit maintainer comment agreeing that the change should be pursued. Keep the discussion in one place. Silence, community support, and previous merged contributions do not replace that agreement.
|
### Commit Conventions
|
||||||
|
Each commit message must adhere to the following format:
|
||||||
|
```
|
||||||
|
<type>(scope): <description> - <issue number>
|
||||||
|
|
||||||
Small documentation corrections and small, unambiguous fixes that restore established behavior may be submitted directly. Provide evidence of the defect. These exceptions do not cover disputed behavior, new product choices, dependencies, or broader refactoring. If the intended behavior is unclear, discuss it first.
|
[optional body]
|
||||||
|
```
|
||||||
|
This improves the readability of the messages
|
||||||
|
|
||||||
For large or cross-cutting changes, agree on the design and a sequence of small, reviewable phases before implementation. Explain why the change belongs in the core, what new complexity or dependencies it introduces, and which simpler alternatives you considered. Consider existing configuration, APIs, plugins, or external tools where appropriate. Splitting a change into PRs does not remove the need to agree on the overall design and scope.
|
#### Type
|
||||||
|
It can be one of the following:
|
||||||
|
1. **feat**: Addition of a new feature
|
||||||
|
2. **fix**: Bug fix
|
||||||
|
3. **sec**: Fixing security issues
|
||||||
|
4. **docs**: Documentation Changes
|
||||||
|
5. **style**: Changes to styling
|
||||||
|
6. **refactor**: Refactoring of code
|
||||||
|
7. **perf**: Code that affects performance
|
||||||
|
8. **test**: Updating or improving the current tests
|
||||||
|
9. **build**: Changes to Build process
|
||||||
|
10. **revert**: Reverting to a previous commit
|
||||||
|
11. **chore** : updating grunt tasks etc
|
||||||
|
|
||||||
## Review your work before opening a PR
|
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||||
|
|
||||||
Before opening any pull request, including a draft, you must personally read and review the entire diff and the relevant surrounding code, understand the implementation, and verify the affected behavior. This applies when an AI agent writes the code or creates the PR for you. An agent's summary or review does not satisfy this requirement. Personally review each subsequent update and verify its effects before pushing it or asking an agent to push it.
|
#### Scope
|
||||||
|
The file or folder where the changes are made. If there are more than one, you can mention any
|
||||||
|
|
||||||
Check the assumptions behind the change. Explain the implementation and important tradeoffs in your own words. For code changes, personally test the affected functionality, add regression tests where practical, and report the commands run and results actually observed. Identify failures, untested cases, and limitations. If an automated test is impractical, explain why and provide manual verification steps.
|
#### Description
|
||||||
|
A short description of the issue
|
||||||
|
|
||||||
Stay involved during review: answer questions, investigate feedback, and revise the work. If you need more time or cannot continue, say so. The maintainer is not committing to finish an abandoned implementation.
|
#### Issue number
|
||||||
|
The issue fixed by this Pull Request.
|
||||||
|
|
||||||
## AI Disclosure
|
The body is optional. It may contain short description of changes made.
|
||||||
|
|
||||||
AI tools may assist professional and hobbyist contributors. All code contributors must be able to personally understand, review, test, and maintain their changes, including security and performance implications; no diploma, job title, or employment is required. If you cannot assess the code yourself, report the problem instead of submitting AI-generated code. Bug reports, documentation improvements, and translations remain welcome.
|
Following all the guidelines an ideal commit will look like:
|
||||||
|
```
|
||||||
|
git commit --signoff -m "feat(themes): New-theme - #834"
|
||||||
|
```
|
||||||
|
|
||||||
AI-assisted work must meet the personal full-diff review and functional testing requirements above before any PR is opened, including drafts or agent-created PRs. You remain responsible for explaining the implementation, addressing feedback, and correcting the code.
|
After committing, push your commits to your forked branch and create a Pull Request from there.
|
||||||
|
The Pull Request Title can be the same as `<type>(scope): <description> - <issue number>`
|
||||||
|
A demo layout of how the Pull request body can look:
|
||||||
|
```
|
||||||
|
Closes <Issue number along with link>
|
||||||
|
|
||||||
Declare whether AI was used to generate or modify submitted code or tests, draft substantive PR text, or create the PR. A simple Yes or No in the PR description is enough. Tool names, prompts, and transcripts are not required.
|
Description (What does the pull request do)
|
||||||
|
|
||||||
AI tools may help you draft, edit, or translate PR explanations and review replies. Review the final text before you send it, make sure you agree with it, and take responsibility for it. Do not send AI-generated text that you have not reviewed to maintainers or other contributors. Please be mindful of the maintainer's time.
|
Changes (What changes were made )
|
||||||
|
|
||||||
## Keep the result maintainable
|
Screenshots or Videos
|
||||||
|
|
||||||
Prefer existing patterns and the smallest clear solution. Justify new abstractions, dependencies, configuration, and compatibility obligations. Include the documentation and tests needed to understand the behavior and change it safely later.
|
Related Issues and Pull Requests(if any)
|
||||||
|
|
||||||
The maintainer must be able to understand and maintain the result without depending on your future availability. An offer to provide ongoing support does not replace this requirement. A working implementation may still be declined if its benefit does not justify its complexity or maintenance cost.
|
```
|
||||||
|
|
||||||
<a id="pull-requests"></a>
|
|
||||||
|
|
||||||
## Submitting and review
|
|
||||||
|
|
||||||
- Check existing [open and closed PRs](https://github.com/navidrome/navidrome/pulls?q=is%3Apr) to avoid duplicate work.
|
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
144
Dockerfile
144
Dockerfile
|
|
@ -2,10 +2,10 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build xx (original image: tonistiigi/xx)
|
### Build xx (original image: tonistiigi/xx)
|
||||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build
|
||||||
|
|
||||||
# v1.9.0
|
# v1.5.0
|
||||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
|
||||||
|
|
||||||
RUN apk add -U --no-cache git
|
RUN apk add -U --no-cache git
|
||||||
RUN git clone https://github.com/tonistiigi/xx && \
|
RUN git clone https://github.com/tonistiigi/xx && \
|
||||||
|
|
@ -24,9 +24,29 @@ RUN cd /out && \
|
||||||
FROM scratch AS xx
|
FROM scratch AS xx
|
||||||
COPY --from=xx-build /out/ /usr/bin/
|
COPY --from=xx-build /out/ /usr/bin/
|
||||||
|
|
||||||
|
########################################################################################################################
|
||||||
|
### Get TagLib
|
||||||
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
ARG CROSS_TAGLIB_VERSION=2.1.1-1
|
||||||
|
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||||
|
|
||||||
|
# wget in busybox can't follow redirects
|
||||||
|
RUN <<EOT
|
||||||
|
apk add --no-cache wget
|
||||||
|
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
|
||||||
|
FILE=taglib-${PLATFORM}.tar.gz
|
||||||
|
|
||||||
|
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
|
||||||
|
wget ${DOWNLOAD_URL}
|
||||||
|
|
||||||
|
mkdir /taglib
|
||||||
|
tar -xzf ${FILE} -C /taglib
|
||||||
|
EOT
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome UI
|
### Build Navidrome UI
|
||||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install node dependencies
|
# Install node dependencies
|
||||||
|
|
@ -42,50 +62,8 @@ FROM scratch AS ui-bundle
|
||||||
COPY --from=ui /build /build
|
COPY --from=ui /build /build
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
### Build Navidrome binary
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
|
||||||
COPY --from=xx / /
|
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
|
|
||||||
RUN apk add --no-cache clang lld file git
|
|
||||||
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
|
|
||||||
RUN xx-verify --setup
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
|
||||||
|
|
||||||
RUN --mount=type=bind,source=. \
|
|
||||||
--mount=type=cache,target=/root/.cache \
|
|
||||||
--mount=type=cache,target=/go/pkg/mod \
|
|
||||||
go mod download
|
|
||||||
|
|
||||||
ARG GIT_SHA
|
|
||||||
ARG GIT_TAG
|
|
||||||
|
|
||||||
RUN --mount=type=bind,source=. \
|
|
||||||
--mount=from=ui,source=/build,target=./ui/build,ro \
|
|
||||||
--mount=type=cache,target=/root/.cache \
|
|
||||||
--mount=type=cache,target=/go/pkg/mod <<EOT
|
|
||||||
set -e
|
|
||||||
xx-go --wrap
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
BUILD_TAGS=$(./release/build-tags.sh)
|
|
||||||
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
|
|
||||||
go build -tags="${BUILD_TAGS}" -ldflags="-w -s \
|
|
||||||
-linkmode=external -extldflags '-latomic' \
|
|
||||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
|
||||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
|
||||||
-o /out/navidrome .
|
|
||||||
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
|
|
||||||
./release/verify-binary.sh /out/navidrome
|
|
||||||
# Fail the build if the binary is accidentally statically linked: dlopen (and
|
|
||||||
# therefore native libwebp detection) only works with a dynamic interpreter.
|
|
||||||
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
|
|
||||||
EOT
|
|
||||||
|
|
||||||
########################################################################################################################
|
|
||||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base
|
|
||||||
RUN apt-get update && apt-get install -y clang lld
|
RUN apt-get update && apt-get install -y clang lld
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
@ -110,13 +88,14 @@ RUN --mount=type=bind,source=. \
|
||||||
--mount=from=ui,source=/build,target=./ui/build,ro \
|
--mount=from=ui,source=/build,target=./ui/build,ro \
|
||||||
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
||||||
--mount=type=cache,target=/root/.cache \
|
--mount=type=cache,target=/root/.cache \
|
||||||
--mount=type=cache,target=/go/pkg/mod <<EOT
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
set -e
|
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
|
||||||
|
|
||||||
# Setup CGO cross-compilation environment
|
# Setup CGO cross-compilation environment
|
||||||
xx-go --wrap
|
xx-go --wrap
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
cat "$(go env GOENV)" 2>/dev/null || true
|
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
|
||||||
|
cat $(go env GOENV)
|
||||||
|
|
||||||
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
||||||
# So let's use gcc for everything except Darwin.
|
# So let's use gcc for everything except Darwin.
|
||||||
|
|
@ -125,25 +104,14 @@ RUN --mount=type=bind,source=. \
|
||||||
export CXX=$(xx-info)-g++
|
export CXX=$(xx-info)-g++
|
||||||
export LD_EXTRA="-extldflags '-static -latomic'"
|
export LD_EXTRA="-extldflags '-static -latomic'"
|
||||||
fi
|
fi
|
||||||
# GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers
|
|
||||||
# (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch
|
|
||||||
# range, making static arm binaries jump to garbage inside glibc's ifunc
|
|
||||||
# resolution and crash before main() (issue #5738). Link 32-bit arm with LLD,
|
|
||||||
# which emits correct addends.
|
|
||||||
if [ "$(xx-info arch)" = "arm" ]; then
|
|
||||||
export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'"
|
|
||||||
fi
|
|
||||||
if [ "$(xx-info os)" = "windows" ]; then
|
if [ "$(xx-info os)" = "windows" ]; then
|
||||||
export EXT=".exe"
|
export EXT=".exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BUILD_TAGS=$(./release/build-tags.sh)
|
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
||||||
go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \
|
|
||||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||||
-o /out/navidrome${EXT} .
|
-o /out/navidrome${EXT} .
|
||||||
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
|
|
||||||
./release/verify-binary.sh /out/navidrome*
|
|
||||||
EOT
|
EOT
|
||||||
|
|
||||||
# Verify if the binary was built for the correct platform and it is statically linked
|
# Verify if the binary was built for the correct platform and it is statically linked
|
||||||
|
|
@ -152,66 +120,28 @@ RUN xx-verify --static /out/navidrome*
|
||||||
FROM scratch AS binary
|
FROM scratch AS binary
|
||||||
COPY --from=build /out /
|
COPY --from=build /out /
|
||||||
|
|
||||||
########################################################################################################################
|
|
||||||
### Build no-op stubs for mpv's video-output libraries
|
|
||||||
# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio.
|
|
||||||
# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls.
|
|
||||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs
|
|
||||||
COPY --from=xx / /
|
|
||||||
RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
RUN xx-apk add --no-cache musl-dev
|
|
||||||
RUN <<EOT
|
|
||||||
set -e
|
|
||||||
mkdir -p /out
|
|
||||||
for so in libEGL.so.1 libgbm.so.1; do
|
|
||||||
readelf -sW /usr/lib/$so \
|
|
||||||
| awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \
|
|
||||||
| sed 's/@.*//' \
|
|
||||||
| grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \
|
|
||||||
| sort -u \
|
|
||||||
| awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c
|
|
||||||
test -s /tmp/stub.c
|
|
||||||
xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c
|
|
||||||
xx-verify /out/$so
|
|
||||||
done
|
|
||||||
EOT
|
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Final Image
|
### Build Final Image
|
||||||
FROM alpine:3.22 AS final
|
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
|
||||||
LABEL maintainer="deluan@navidrome.org"
|
LABEL maintainer="deluan@navidrome.org"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install ffmpeg and mpv
|
||||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
RUN apk add -U --no-cache ffmpeg mpv sqlite
|
||||||
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
|
|
||||||
# otherwise the deleted bytes still ship in the image.
|
|
||||||
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
|
||||||
for lib in libwebp libwebpdemux libwebpmux; do \
|
|
||||||
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
|
||||||
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
|
||||||
done && \
|
|
||||||
rm -rf /usr/lib/gallium-pipe /usr/lib/dri \
|
|
||||||
/usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \
|
|
||||||
/usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so*
|
|
||||||
|
|
||||||
COPY --from=mpv-stubs /out/ /usr/lib/
|
# Copy navidrome binary
|
||||||
RUN mpv --no-video --ao=null --version > /dev/null
|
COPY --from=build /out/navidrome /app/
|
||||||
|
|
||||||
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
|
||||||
COPY --from=build-alpine /out/navidrome /app/
|
|
||||||
|
|
||||||
VOLUME ["/data", "/music"]
|
VOLUME ["/data", "/music"]
|
||||||
ENV ND_MUSICFOLDER=/music
|
ENV ND_MUSICFOLDER=/music
|
||||||
ENV ND_DATAFOLDER=/data
|
ENV ND_DATAFOLDER=/data
|
||||||
ENV ND_CONFIGFILE=/data/navidrome.toml
|
ENV ND_CONFIGFILE=/data/navidrome.toml
|
||||||
ENV ND_PORT=4533
|
ENV ND_PORT=4533
|
||||||
|
ENV GODEBUG="asyncpreemptoff=1"
|
||||||
RUN touch /.nddockerenv
|
RUN touch /.nddockerenv
|
||||||
|
|
||||||
EXPOSE ${ND_PORT}
|
EXPOSE ${ND_PORT}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PATH="/app:${PATH}"
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/navidrome"]
|
ENTRYPOINT ["/app/navidrome"]
|
||||||
|
|
||||||
|
|
|
||||||
158
Makefile
158
Makefile
|
|
@ -1,30 +1,22 @@
|
||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
NODE_VERSION=$(shell cat .nvmrc)
|
||||||
|
|
||||||
comma:=,
|
|
||||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
|
||||||
|
|
||||||
# Set global environment variables, required for most targets
|
|
||||||
export ND_ENABLEINSIGHTSCOLLECTOR=false
|
|
||||||
|
|
||||||
ifneq ("$(wildcard .git/HEAD)","")
|
ifneq ("$(wildcard .git/HEAD)","")
|
||||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||||
GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT
|
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||||
else
|
else
|
||||||
GIT_SHA=source_archive
|
GIT_SHA=source_archive
|
||||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||||
endif
|
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,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= navidrome/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||||
VACUUM_VERSION ?= v0.30.6
|
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
GOLANGCI_LINT_VERSION ?= v2.5.0
|
||||||
OASDIFF_VERSION ?= v1.32.1
|
|
||||||
API_DIFF_BASE ?= origin/master
|
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
|
|
@ -34,11 +26,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
|
||||||
.PHONY: setup
|
.PHONY: setup
|
||||||
|
|
||||||
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
||||||
npx foreman -j Procfile.dev -p 4533 start
|
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
|
|
||||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||||
go tool reflex -d none -c reflex.conf
|
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
||||||
.PHONY: server
|
.PHONY: server
|
||||||
|
|
||||||
stop: ##@Development Stop development servers (UI and backend)
|
stop: ##@Development Stop development servers (UI and backend)
|
||||||
|
|
@ -50,23 +42,19 @@ stop: ##@Development Stop development servers (UI and backend)
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||||
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
go tool ginkgo watch -tags=netgo -notify ./...
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
|
|
||||||
PKG ?= ./...
|
PKG ?= ./...
|
||||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||||
go test -tags $(GO_BUILD_TAGS) $(PKG)
|
go test -tags netgo $(PKG)
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
||||||
cd plugins/cmd/ndpgen && go test ./......
|
|
||||||
.PHONY: test-ndpgen
|
|
||||||
|
|
||||||
testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
|
||||||
.PHONY: testall
|
.PHONY: testall
|
||||||
|
|
||||||
test-race: ##@Development Run Go tests with race detector
|
test-race: ##@Development Run Go tests with race detector
|
||||||
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
go test -tags netgo -race -shuffle=on ./...
|
||||||
.PHONY: test-race
|
.PHONY: test-race
|
||||||
|
|
||||||
test-js: ##@Development Run JS tests
|
test-js: ##@Development Run JS tests
|
||||||
|
|
@ -79,8 +67,8 @@ test-i18n: ##@Development Validate all translations files
|
||||||
|
|
||||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
@INSTALL=false; \
|
@INSTALL=false; \
|
||||||
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
|
if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \
|
||||||
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
||||||
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
||||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||||
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
||||||
|
|
@ -96,47 +84,8 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
fi
|
fi
|
||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
|
||||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
|
||||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
|
||||||
echo "Installing OpenAPI tools..."; \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
|
||||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
|
||||||
fi
|
|
||||||
.PHONY: install-api-tools
|
|
||||||
|
|
||||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
|
||||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
|
||||||
.PHONY: api-lint
|
|
||||||
|
|
||||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
|
||||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
|
||||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
|
||||||
.PHONY: api-bundle
|
|
||||||
|
|
||||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
|
||||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
|
||||||
.PHONY: api-gen
|
|
||||||
|
|
||||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
|
||||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
|
||||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
|
||||||
if [ -z "$$BASE" ]; then \
|
|
||||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
|
||||||
BASE=$(API_DIFF_BASE); \
|
|
||||||
fi; \
|
|
||||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
|
||||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
|
||||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
|
||||||
else \
|
|
||||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
|
||||||
fi
|
|
||||||
.PHONY: api-diff
|
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
|
|
@ -151,19 +100,9 @@ format: ##@Development Format code
|
||||||
.PHONY: format
|
.PHONY: format
|
||||||
|
|
||||||
wire: check_go_env ##@Development Update Dependency Injection
|
wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags=netgo ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
|
||||||
go generate ./...
|
|
||||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
|
||||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
|
||||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
|
|
||||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
|
|
||||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
|
|
||||||
go mod tidy -C plugins/pdk/go
|
|
||||||
.PHONY: gen
|
|
||||||
|
|
||||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||||
.PHONY: snapshots
|
.PHONY: snapshots
|
||||||
|
|
@ -188,14 +127,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||||
.PHONY: setup-git
|
.PHONY: setup-git
|
||||||
|
|
||||||
build: check_go_env buildjs ##@Build Build the project
|
build: check_go_env buildjs ##@Build Build the project
|
||||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
buildall: deprecated build
|
buildall: deprecated build
|
||||||
.PHONY: buildall
|
.PHONY: buildall
|
||||||
|
|
||||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: debug-build
|
.PHONY: debug-build
|
||||||
|
|
||||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||||
|
|
@ -220,10 +159,11 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
||||||
--platform $(PLATFORMS) \
|
--platform $(PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
|
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||||
--output "./binaries" --target binary .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
|
|
||||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
||||||
|
|
@ -231,6 +171,7 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navi
|
||||||
--platform $(IMAGE_PLATFORMS) \
|
--platform $(IMAGE_PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
|
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||||
--tag $(DOCKER_TAG) .
|
--tag $(DOCKER_TAG) .
|
||||||
.PHONY: docker-image
|
.PHONY: docker-image
|
||||||
|
|
||||||
|
|
@ -243,8 +184,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
||||||
@du -h binaries/msi/*.msi
|
@du -h binaries/msi/*.msi
|
||||||
.PHONY: docker-msi
|
.PHONY: docker-msi
|
||||||
|
|
||||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||||
if [ -f navidrome.toml ]; then \
|
if [ -f navidrome.toml ]; then \
|
||||||
|
|
@ -255,7 +196,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
|
||||||
fi; \
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||||
.PHONY: docker-run
|
.PHONY: run-docker
|
||||||
|
|
||||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
||||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||||
|
|
@ -274,39 +215,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
||||||
.PHONY: get-music
|
.PHONY: get-music
|
||||||
|
|
||||||
|
|
||||||
##########################################
|
|
||||||
#### Worktrees
|
|
||||||
|
|
||||||
WORKTREES_DIR := .worktrees
|
|
||||||
|
|
||||||
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
|
|
||||||
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
|
|
||||||
@mkdir -p $(WORKTREES_DIR)
|
|
||||||
@echo "Creating worktree for branch '${name}'..."
|
|
||||||
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
|
|
||||||
git worktree add $(WORKTREES_DIR)/${name} ${name}
|
|
||||||
@if [ -n "${go}" ]; then \
|
|
||||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
|
|
||||||
else \
|
|
||||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
|
|
||||||
fi
|
|
||||||
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
|
|
||||||
@echo " cd $(WORKTREES_DIR)/${name}"
|
|
||||||
.PHONY: wt
|
|
||||||
|
|
||||||
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
|
|
||||||
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
|
|
||||||
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
|
|
||||||
@echo "Removing worktree '${name}'..."
|
|
||||||
@git worktree remove --force $(WORKTREES_DIR)/${name}
|
|
||||||
@echo "Worktree '${name}' removed."
|
|
||||||
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
|
|
||||||
.PHONY: rm-wt
|
|
||||||
|
|
||||||
ls-wt: ##@Worktrees List all active git worktrees
|
|
||||||
@git worktree list
|
|
||||||
.PHONY: ls-wt
|
|
||||||
|
|
||||||
##########################################
|
##########################################
|
||||||
#### Miscellaneous
|
#### Miscellaneous
|
||||||
|
|
||||||
|
|
@ -358,6 +266,24 @@ deprecated:
|
||||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||||
.PHONY: deprecated
|
.PHONY: deprecated
|
||||||
|
|
||||||
|
# Generate Go code from plugins/api/api.proto
|
||||||
|
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
|
||||||
|
go generate ./plugins/...
|
||||||
|
.PHONY: plugin-gen
|
||||||
|
|
||||||
|
plugin-examples: check_go_env ##@Development Build all example plugins
|
||||||
|
$(MAKE) -C plugins/examples clean all
|
||||||
|
.PHONY: plugin-examples
|
||||||
|
|
||||||
|
plugin-clean: check_go_env ##@Development Clean all plugins
|
||||||
|
$(MAKE) -C plugins/examples clean
|
||||||
|
$(MAKE) -C plugins/testdata clean
|
||||||
|
.PHONY: plugin-clean
|
||||||
|
|
||||||
|
plugin-tests: check_go_env ##@Development Build all test plugins
|
||||||
|
$(MAKE) -C plugins/testdata clean all
|
||||||
|
.PHONY: plugin-tests
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
HELP_FUN = \
|
HELP_FUN = \
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
[](https://github.com/navidrome/navidrome/releases)
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||||
[](https://hub.docker.com/r/navidrome/navidrome)
|
[](https://hub.docker.com/r/deluan/navidrome)
|
||||||
[](https://discord.gg/xh7j7yF)
|
[](https://discord.gg/xh7j7yF)
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
[](https://www.reddit.com/r/navidrome/)
|
||||||
[](CODE_OF_CONDUCT.md)
|
[](CODE_OF_CONDUCT.md)
|
||||||
|
|
@ -21,11 +21,9 @@ the `master` branch in order to get a stable set of binaries.
|
||||||
|
|
||||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
||||||
|
|
||||||
__Any feedback is welcome!__ If you find a bug, please file a [GitHub issue](https://github.com/navidrome/navidrome/issues/new/choose).
|
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||||
If you need/want a new feature or think of any way to improve Navidrome, please share it in
|
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
||||||
[Discussions](https://github.com/navidrome/navidrome/discussions/categories/ideas) or join the discussion in our
|
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||||
[Subreddit](https://www.reddit.com/r/navidrome/). Before contributing code, please read the
|
|
||||||
[Contribution Guide](CONTRIBUTING.md). If you want to contribute to the project in any other way
|
|
||||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||||
|
|
@ -37,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
|
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||||
|
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||||
<br>
|
|
||||||
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
|
||||||
<br>
|
|
||||||
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://www.pikapods.com/pods?run=navidrome)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -61,7 +52,6 @@ Several cloud hosting providers partner with us to offer [officially supported,
|
||||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||||
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
|
|
||||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||||
|
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const apiBaseURL = "https://api.deezer.com"
|
|
||||||
const authBaseURL = "https://auth.deezer.com"
|
|
||||||
|
|
||||||
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
|
|
||||||
// and no rate-limit headers, so the body code is the only signal.
|
|
||||||
const errCodeQuota = 4
|
|
||||||
|
|
||||||
type deezerError struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *deezerError) Error() string {
|
|
||||||
return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
type httpDoer interface {
|
|
||||||
Do(req *http.Request) (*http.Response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
httpDoer httpDoer
|
|
||||||
jwt jwtToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClient(hc httpDoer) *client {
|
|
||||||
return &client{
|
|
||||||
httpDoer: hc,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Add("q", name)
|
|
||||||
params.Add("order", "RANKING")
|
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.URL.RawQuery = params.Encode()
|
|
||||||
|
|
||||||
var results SearchArtistResults
|
|
||||||
err = c.makeRequest(req, &results)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results.Data) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
return results.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) makeRequest(req *http.Request, response any) error {
|
|
||||||
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.httpDoer.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checked before the status: a throttled request still answers 200, and decoding its body
|
|
||||||
// into a result type yields an empty one, which reads as "nothing found".
|
|
||||||
if err := parseBodyError(data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Unmarshal(data, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
|
|
||||||
func parseBodyError(data []byte) error {
|
|
||||||
var body errorResponse
|
|
||||||
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
|
|
||||||
_ = json.Unmarshal(data, &body)
|
|
||||||
switch {
|
|
||||||
case body.Error == nil:
|
|
||||||
return nil
|
|
||||||
case body.Error.Code == errCodeQuota:
|
|
||||||
return errors.Join(body.Error, agents.ErrRetryLater)
|
|
||||||
default:
|
|
||||||
return body.Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var results RelatedArtists
|
|
||||||
err = c.makeRequest(req, &results)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.URL.RawQuery = params.Encode()
|
|
||||||
|
|
||||||
var results TopTracks
|
|
||||||
err = c.makeRequest(req, &results)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipeAPIURL = "https://pipe.deezer.com/api"
|
|
||||||
|
|
||||||
var strictPolicy = bluemonday.StrictPolicy()
|
|
||||||
|
|
||||||
func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
|
|
||||||
jwt, err := c.getJWT(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := map[string]any{
|
|
||||||
"operationName": "ArtistBio",
|
|
||||||
"variables": map[string]any{
|
|
||||||
"artistId": strconv.Itoa(artistID),
|
|
||||||
},
|
|
||||||
"query": `query ArtistBio($artistId: String!) {
|
|
||||||
artist(artistId: $artistId) {
|
|
||||||
bio {
|
|
||||||
full
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(query)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept-Language", lang)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
|
||||||
|
|
||||||
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
|
|
||||||
resp, err := c.httpDoer.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
type graphQLResponse struct {
|
|
||||||
Data struct {
|
|
||||||
Artist struct {
|
|
||||||
Bio struct {
|
|
||||||
Full string `json:"full"`
|
|
||||||
} `json:"bio"`
|
|
||||||
} `json:"artist"`
|
|
||||||
} `json:"data"`
|
|
||||||
Errors []struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result graphQLResponse
|
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Errors) > 0 {
|
|
||||||
var errs []error
|
|
||||||
for m := range result.Errors {
|
|
||||||
errs = append(errs, errors.New(result.Errors[m].Message))
|
|
||||||
}
|
|
||||||
err := errors.Join(errs...)
|
|
||||||
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Data.Artist.Bio.Full == "" {
|
|
||||||
return "", errors.New("deezer: biography not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanBio(result.Data.Artist.Bio.Full), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanBio(bio string) string {
|
|
||||||
bio = strings.ReplaceAll(bio, "</p>", "\n")
|
|
||||||
return strictPolicy.Sanitize(bio)
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type jwtToken struct {
|
|
||||||
token string
|
|
||||||
expiresAt time.Time
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jwtToken) get() (string, bool) {
|
|
||||||
j.mu.RLock()
|
|
||||||
defer j.mu.RUnlock()
|
|
||||||
if time.Now().Before(j.expiresAt) {
|
|
||||||
return j.token, true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *jwtToken) set(token string, expiresIn time.Duration) {
|
|
||||||
j.mu.Lock()
|
|
||||||
defer j.mu.Unlock()
|
|
||||||
j.token = token
|
|
||||||
j.expiresAt = time.Now().Add(expiresIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getJWT(ctx context.Context) (string, error) {
|
|
||||||
// Check if we have a valid cached token
|
|
||||||
if token, valid := c.jwt.get(); valid {
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch a new anonymous token
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.httpDoer.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
type authResponse struct {
|
|
||||||
JWT string `json:"jwt"` //nolint:gosec
|
|
||||||
}
|
|
||||||
|
|
||||||
var result authResponse
|
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.JWT == "" {
|
|
||||||
return "", errors.New("deezer: no JWT token in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JWT to get actual expiration time
|
|
||||||
token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
|
||||||
expiresAt, ok := token.Expiration()
|
|
||||||
if !ok || expiresAt.IsZero() {
|
|
||||||
return "", errors.New("deezer: JWT token has no expiration time")
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := time.Until(expiresAt) - 1*time.Minute
|
|
||||||
if ttl <= 0 {
|
|
||||||
return "", errors.New("deezer: JWT token already expired or expires too soon")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.jwt.set(result.JWT, ttl)
|
|
||||||
log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
|
|
||||||
|
|
||||||
return result.JWT, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,294 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("JWT Authentication", func() {
|
|
||||||
var httpClient *fakeHttpClient
|
|
||||||
var client *client
|
|
||||||
var ctx context.Context
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &fakeHttpClient{}
|
|
||||||
client = newClient(httpClient)
|
|
||||||
ctx = context.Background()
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("getJWT", func() {
|
|
||||||
Context("with a valid JWT response", func() {
|
|
||||||
It("successfully fetches and caches a JWT token", func() {
|
|
||||||
testJWT := createTestJWT(5 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
token, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token).To(Equal(testJWT))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the cached token on subsequent calls", func() {
|
|
||||||
testJWT := createTestJWT(5 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
// First call should fetch from API
|
|
||||||
token1, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token1).To(Equal(testJWT))
|
|
||||||
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
|
|
||||||
|
|
||||||
// Second call should return cached token without hitting API
|
|
||||||
httpClient.lastRequest = nil // Clear last request to verify no new request is made
|
|
||||||
token2, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token2).To(Equal(testJWT))
|
|
||||||
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses the JWT expiration time correctly", func() {
|
|
||||||
expectedExpiration := time.Now().Add(5 * time.Minute)
|
|
||||||
testToken, err := jwt.NewBuilder().
|
|
||||||
Expiration(expectedExpiration).
|
|
||||||
Build()
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
|
|
||||||
})
|
|
||||||
|
|
||||||
token, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token).ToNot(BeEmpty())
|
|
||||||
|
|
||||||
// Verify the token is cached until close to expiration
|
|
||||||
// The cache should expire 1 minute before the JWT expires
|
|
||||||
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
|
|
||||||
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with JWT tokens that expire soon", func() {
|
|
||||||
It("rejects tokens that expire in less than 1 minute", func() {
|
|
||||||
// Create a token that expires in 30 seconds (less than 1-minute buffer)
|
|
||||||
testJWT := createTestJWT(30 * time.Second)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects already expired tokens", func() {
|
|
||||||
// Create a token that expired 1 minute ago
|
|
||||||
testJWT := createTestJWT(-1 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("accepts tokens that expire in more than 1 minute", func() {
|
|
||||||
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
|
|
||||||
testJWT := createTestJWT(2 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
token, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with invalid responses", func() {
|
|
||||||
It("handles HTTP error responses", func() {
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 500,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles malformed JSON responses", func() {
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles responses with empty JWT field", func() {
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles invalid JWT tokens", func() {
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects JWT tokens without expiration", func() {
|
|
||||||
// Create a JWT without expiration claim
|
|
||||||
testToken, err := jwt.NewBuilder().
|
|
||||||
Claim("custom", "value").
|
|
||||||
Build()
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
// Verify token has no expiration
|
|
||||||
_, hasExp := testToken.Expiration()
|
|
||||||
Expect(hasExp).To(BeFalse())
|
|
||||||
|
|
||||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = client.getJWT(ctx)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("token caching behavior", func() {
|
|
||||||
It("fetches a new token when the cached token expires", func() {
|
|
||||||
// First token expires in 5 minutes
|
|
||||||
firstJWT := createTestJWT(5 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
token1, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token1).To(Equal(firstJWT))
|
|
||||||
|
|
||||||
// Manually expire the cached token
|
|
||||||
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
|
|
||||||
|
|
||||||
// Second token with different expiration (10 minutes)
|
|
||||||
secondJWT := createTestJWT(10 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
token2, err := client.getJWT(ctx)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(token2).To(Equal(secondJWT))
|
|
||||||
Expect(token2).ToNot(Equal(token1))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("jwtToken cache", func() {
|
|
||||||
var cache *jwtToken
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
cache = &jwtToken{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns false for expired tokens", func() {
|
|
||||||
cache.set("test-token", -1*time.Second) // Already expired
|
|
||||||
token, valid := cache.get()
|
|
||||||
Expect(valid).To(BeFalse())
|
|
||||||
Expect(token).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns true for valid tokens", func() {
|
|
||||||
cache.set("test-token", 4*time.Minute)
|
|
||||||
token, valid := cache.get()
|
|
||||||
Expect(valid).To(BeTrue())
|
|
||||||
Expect(token).To(Equal("test-token"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is thread-safe for concurrent access", func() {
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
|
|
||||||
// Writer goroutine
|
|
||||||
wg.Go(func() {
|
|
||||||
for i := range 100 {
|
|
||||||
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
|
||||||
time.Sleep(1 * time.Millisecond)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reader goroutine
|
|
||||||
wg.Go(func() {
|
|
||||||
for range 100 {
|
|
||||||
cache.get()
|
|
||||||
time.Sleep(1 * time.Millisecond)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for both goroutines to complete
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Verify final state is valid
|
|
||||||
token, valid := cache.get()
|
|
||||||
Expect(valid).To(BeTrue())
|
|
||||||
Expect(token).To(HavePrefix("token-"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// createTestJWT creates a valid JWT token for testing purposes
|
|
||||||
func createTestJWT(expiresIn time.Duration) string {
|
|
||||||
token, err := jwt.NewBuilder().
|
|
||||||
Expiration(time.Now().Add(expiresIn)).
|
|
||||||
Build()
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("failed to create test JWT: %v", err))
|
|
||||||
}
|
|
||||||
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
|
|
||||||
}
|
|
||||||
return string(signed)
|
|
||||||
}
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("client", func() {
|
|
||||||
var httpClient *fakeHttpClient
|
|
||||||
var client *client
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &fakeHttpClient{}
|
|
||||||
client = newClient(httpClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ArtistImages", func() {
|
|
||||||
It("returns artist images from a successful request", func() {
|
|
||||||
f, err := os.Open("tests/fixtures/deezer.search.artist.json")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
|
|
||||||
|
|
||||||
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(artists).To(HaveLen(17))
|
|
||||||
Expect(artists[0].Name).To(Equal("Michael Jackson"))
|
|
||||||
Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if artist was not found", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal.
|
|
||||||
It("reports an exhausted quota as a retryable error, not as a missing artist", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(
|
|
||||||
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err).ToNot(MatchError(agents.ErrNotFound),
|
|
||||||
"a throttled lookup would otherwise settle the artist as having no image")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("Quota limit exceeded"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports a non-quota body error as a plain error", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(
|
|
||||||
`{"error":{"type":"Exception","message":"Invalid query","code":100}}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err).ToNot(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(),
|
|
||||||
"only a throttle asks the caller to come back later")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("TopTracks", func() {
|
|
||||||
It("returns top tracks with artist and album info from a successful request", func() {
|
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.top.json")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
|
|
||||||
|
|
||||||
tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(tracks).To(HaveLen(5))
|
|
||||||
|
|
||||||
// Verify first track has all expected fields
|
|
||||||
Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
|
|
||||||
Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
|
|
||||||
Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
|
|
||||||
|
|
||||||
// Verify second track
|
|
||||||
Expect(tracks[1].Title).To(Equal("One More Time"))
|
|
||||||
Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
|
|
||||||
Expect(tracks[1].Album.Title).To(Equal("Discovery"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ArtistBio", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
|
|
||||||
testJWT := createTestJWT(5 * time.Minute)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns artist bio from a successful request", func() {
|
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
|
||||||
|
|
||||||
bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
|
||||||
Expect(bio).ToNot(ContainSubstring("<p>"))
|
|
||||||
Expect(bio).ToNot(ContainSubstring("</p>"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses the provided language", func() {
|
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
|
||||||
|
|
||||||
_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("includes the JWT token in the request", func() {
|
|
||||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
|
||||||
|
|
||||||
_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
// Verify that the Authorization header has the Bearer token format
|
|
||||||
authHeader := httpClient.lastRequest.Header.Get("Authorization")
|
|
||||||
Expect(authHeader).To(HavePrefix("Bearer "))
|
|
||||||
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles GraphQL errors", func() {
|
|
||||||
errorResponse := `{
|
|
||||||
"data": {
|
|
||||||
"artist": {
|
|
||||||
"bio": {
|
|
||||||
"full": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"message": "Artist not found"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"message": "Invalid artist ID"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
|
|
||||||
Expect(err.Error()).To(ContainSubstring("Artist not found"))
|
|
||||||
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles empty biography", func() {
|
|
||||||
emptyBioResponse := `{
|
|
||||||
"data": {
|
|
||||||
"artist": {
|
|
||||||
"bio": {
|
|
||||||
"full": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
|
||||||
Expect(err).To(MatchError("deezer: biography not found"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles JWT token fetch failure", func() {
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 500,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles JWT token that expires too soon", func() {
|
|
||||||
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
|
|
||||||
expiredJWT := createTestJWT(30 * time.Second)
|
|
||||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
type fakeHttpClient struct {
|
|
||||||
responses map[string]*http.Response
|
|
||||||
lastRequest *http.Request
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeHttpClient) mock(url string, response http.Response) {
|
|
||||||
if c.responses == nil {
|
|
||||||
c.responses = make(map[string]*http.Response)
|
|
||||||
}
|
|
||||||
c.responses[url] = &response
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
|
|
||||||
c.lastRequest = req
|
|
||||||
u := req.URL
|
|
||||||
u.RawQuery = ""
|
|
||||||
if resp, ok := c.responses[u.String()]; ok {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
panic("URL not mocked: " + u.String())
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
)
|
|
||||||
|
|
||||||
const deezerAgentName = "deezer"
|
|
||||||
const deezerApiPictureXlSize = 1000
|
|
||||||
const deezerApiPictureBigSize = 500
|
|
||||||
const deezerApiPictureMediumSize = 250
|
|
||||||
const deezerApiPictureSmallSize = 56
|
|
||||||
const deezerArtistSearchLimit = 50
|
|
||||||
|
|
||||||
type deezerAgent struct {
|
|
||||||
dataStore model.DataStore
|
|
||||||
client *client
|
|
||||||
languages []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
|
||||||
agent := &deezerAgent{
|
|
||||||
dataStore: dataStore,
|
|
||||||
languages: conf.Server.Deezer.Languages,
|
|
||||||
}
|
|
||||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
|
||||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
|
||||||
agent.client = newClient(cachedHttpClient)
|
|
||||||
return agent
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *deezerAgent) AgentName() string {
|
|
||||||
return deezerAgentName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
|
|
||||||
artist, err := s.searchArtist(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, agents.ErrNotFound) {
|
|
||||||
log.Warn(ctx, "Artist not found in deezer", "artist", name)
|
|
||||||
} else {
|
|
||||||
log.Error(ctx, "Error calling deezer", "artist", name, err)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var res []agents.ExternalImage
|
|
||||||
possibleImages := []struct {
|
|
||||||
URL string
|
|
||||||
Size int
|
|
||||||
}{
|
|
||||||
{artist.PictureXl, deezerApiPictureXlSize},
|
|
||||||
{artist.PictureBig, deezerApiPictureBigSize},
|
|
||||||
{artist.PictureMedium, deezerApiPictureMediumSize},
|
|
||||||
{artist.PictureSmall, deezerApiPictureSmallSize},
|
|
||||||
}
|
|
||||||
for _, imgData := range possibleImages {
|
|
||||||
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
|
|
||||||
res = append(res, agents.ExternalImage{
|
|
||||||
URL: imgData.URL,
|
|
||||||
Size: imgData.Size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(res) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture
|
|
||||||
// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host.
|
|
||||||
const deezerEmptyPicturePath = "/images/artist//"
|
|
||||||
|
|
||||||
func isPlaceholderPicture(url string) bool {
|
|
||||||
return strings.Contains(url, deezerEmptyPicturePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
|
||||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
|
|
||||||
for i := range artists {
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
|
|
||||||
if i > 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
|
|
||||||
// ahead of non-matches, prefer an exact-case match, then the most fans.
|
|
||||||
rank := func(a Artist) int {
|
|
||||||
switch {
|
|
||||||
case a.Name == name:
|
|
||||||
return 2
|
|
||||||
case strings.EqualFold(a.Name, name):
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.SortFunc(artists, func(a, b Artist) int {
|
|
||||||
return cmp.Or(
|
|
||||||
cmp.Compare(rank(b), rank(a)),
|
|
||||||
cmp.Compare(b.NbFan, a.NbFan),
|
|
||||||
cmp.Compare(a.ID, b.ID),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
best := artists[0]
|
|
||||||
if !strings.EqualFold(best.Name, name) {
|
|
||||||
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
|
|
||||||
return new(best), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
|
||||||
artist, err := s.searchArtist(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
related, err := s.client.getRelatedArtists(ctx, artist.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res := slice.Map(related, func(r Artist) agents.Artist {
|
|
||||||
return agents.Artist{
|
|
||||||
Name: r.Name,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if len(res) > limit {
|
|
||||||
res = res[:limit]
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
|
|
||||||
artist, err := s.searchArtist(ctx, artistName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res := slice.Map(tracks, func(r Track) agents.Song {
|
|
||||||
return agents.Song{
|
|
||||||
Name: r.Title,
|
|
||||||
Album: r.Album.Title,
|
|
||||||
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
|
|
||||||
artist, err := s.searchArtist(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, lang := range s.languages {
|
|
||||||
bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
|
|
||||||
if err == nil && bio != "" {
|
|
||||||
return bio, nil
|
|
||||||
}
|
|
||||||
log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
|
|
||||||
}
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
conf.AddHook(func() {
|
|
||||||
if conf.Server.Deezer.Enabled {
|
|
||||||
agents.Register(deezerAgentName, deezerConstructor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,296 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("deezerAgent", func() {
|
|
||||||
var ctx context.Context
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = context.Background()
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Deezer.Enabled = true
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("deezerConstructor", func() {
|
|
||||||
It("uses configured languages", func() {
|
|
||||||
conf.Server.Deezer.Languages = []string{"pt", "en"}
|
|
||||||
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
|
||||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("searchArtist", func() {
|
|
||||||
var agent *deezerAgent
|
|
||||||
var httpClient *fakeHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &fakeHttpClient{}
|
|
||||||
agent = &deezerAgent{
|
|
||||||
dataStore: &tests.MockDataStore{},
|
|
||||||
client: newClient(httpClient),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("picks the exact-name match with the most fans when several share the name", func() {
|
|
||||||
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":61045802,"name":"Queen","nb_fan":75},
|
|
||||||
{"id":141954732,"name":"Queen","nb_fan":397},
|
|
||||||
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
|
|
||||||
{"id":183179807,"name":"Queen","nb_fan":53},
|
|
||||||
{"id":412,"name":"Queen","nb_fan":12744378}
|
|
||||||
],"total":5}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
artist, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(artist.ID).To(Equal(412))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("matches the name case-insensitively", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":1,"name":"QUEEN","nb_fan":10},
|
|
||||||
{"id":2,"name":"queen","nb_fan":20}
|
|
||||||
],"total":2}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
artist, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(artist.ID).To(Equal(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
// The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled
|
|
||||||
// lookup reaching that here would record a permanent absence.
|
|
||||||
It("surfaces an exhausted quota instead of reporting the artist as not found", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(
|
|
||||||
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err).ToNot(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when no result matches the name exactly", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
|
|
||||||
],"total":1}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistImages", func() {
|
|
||||||
var agent *deezerAgent
|
|
||||||
var httpClient *fakeHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &fakeHttpClient{}
|
|
||||||
agent = &deezerAgent{
|
|
||||||
dataStore: &tests.MockDataStore{},
|
|
||||||
client: newClient(httpClient),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the real images when the artist has a picture", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
|
||||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg",
|
|
||||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"}
|
|
||||||
],"total":1}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(images).To(HaveLen(2))
|
|
||||||
Expect(images[0].URL).To(ContainSubstring("1000x1000"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
|
||||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg",
|
|
||||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg",
|
|
||||||
"picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg",
|
|
||||||
"picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"}
|
|
||||||
],"total":1}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(images).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistBiography - Language Fallback", func() {
|
|
||||||
var agent *deezerAgent
|
|
||||||
var httpClient *langAwareHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = newLangAwareHttpClient()
|
|
||||||
|
|
||||||
// Mock search artist (returns Michael Jackson)
|
|
||||||
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
|
|
||||||
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
|
|
||||||
|
|
||||||
// Mock JWT token
|
|
||||||
testJWT := createTestJWT(5 * time.Minute)
|
|
||||||
httpClient.jwtResponse = &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setupAgent := func(languages []string) {
|
|
||||||
conf.Server.Deezer.Languages = languages
|
|
||||||
agent = &deezerAgent{
|
|
||||||
dataStore: &tests.MockDataStore{},
|
|
||||||
client: newClient(httpClient),
|
|
||||||
languages: languages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
It("returns content in first language when available (1 bio API call)", func() {
|
|
||||||
setupAgent([]string{"fr", "en"})
|
|
||||||
|
|
||||||
// French biography available
|
|
||||||
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
|
|
||||||
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
|
|
||||||
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
|
|
||||||
Expect(httpClient.bioRequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to second language when first returns empty (2 bio API calls)", func() {
|
|
||||||
setupAgent([]string{"ja", "en"})
|
|
||||||
|
|
||||||
// Japanese returns empty biography
|
|
||||||
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
|
||||||
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
// English returns full biography
|
|
||||||
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
|
||||||
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
|
|
||||||
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
|
||||||
Expect(httpClient.bioRequestCount).To(Equal(2))
|
|
||||||
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
|
|
||||||
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when all languages return empty", func() {
|
|
||||||
setupAgent([]string{"ja", "xx"})
|
|
||||||
|
|
||||||
// Both languages return empty biography
|
|
||||||
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
|
||||||
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
|
||||||
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(httpClient.bioRequestCount).To(Equal(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
|
|
||||||
type langAwareHttpClient struct {
|
|
||||||
searchResponse *http.Response
|
|
||||||
jwtResponse *http.Response
|
|
||||||
bioResponses map[string]*http.Response
|
|
||||||
bioRequests []*http.Request
|
|
||||||
bioRequestCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLangAwareHttpClient() *langAwareHttpClient {
|
|
||||||
return &langAwareHttpClient{
|
|
||||||
bioResponses: make(map[string]*http.Response),
|
|
||||||
bioRequests: make([]*http.Request, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
|
|
||||||
// Handle search artist request
|
|
||||||
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
|
|
||||||
if c.searchResponse != nil {
|
|
||||||
return c.searchResponse, nil
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle JWT token request
|
|
||||||
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
|
|
||||||
if c.jwtResponse != nil {
|
|
||||||
return c.jwtResponse, nil
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 500,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle bio request (GraphQL API)
|
|
||||||
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
|
|
||||||
c.bioRequestCount++
|
|
||||||
c.bioRequests = append(c.bioRequests, req)
|
|
||||||
lang := req.Header.Get("Accept-Language")
|
|
||||||
if resp, ok := c.bioResponses[lang]; ok {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
// Return empty bio by default
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("URL not mocked: " + req.URL.String())
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
type SearchArtistResults struct {
|
|
||||||
Data []Artist `json:"data"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Next string `json:"next"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Artist struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
PictureSmall string `json:"picture_small"`
|
|
||||||
PictureMedium string `json:"picture_medium"`
|
|
||||||
PictureBig string `json:"picture_big"`
|
|
||||||
PictureXl string `json:"picture_xl"`
|
|
||||||
NbAlbum int `json:"nb_album"`
|
|
||||||
NbFan int `json:"nb_fan"`
|
|
||||||
Radio bool `json:"radio"`
|
|
||||||
Tracklist string `json:"tracklist"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorResponse struct {
|
|
||||||
Error *deezerError `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RelatedArtists struct {
|
|
||||||
Data []Artist `json:"data"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TopTracks struct {
|
|
||||||
Data []Track `json:"data"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Next string `json:"next"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Track struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
Rank int `json:"rank"`
|
|
||||||
Preview string `json:"preview"`
|
|
||||||
Artist Artist `json:"artist"`
|
|
||||||
Album Album `json:"album"`
|
|
||||||
Contributors []Artist `json:"contributors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Album struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
CoverSmall string `json:"cover_small"`
|
|
||||||
CoverMedium string `json:"cover_medium"`
|
|
||||||
CoverBig string `json:"cover_big"`
|
|
||||||
CoverXl string `json:"cover_xl"`
|
|
||||||
Tracklist string `json:"tracklist"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
// Package gotaglib provides an alternative metadata extractor using go-taglib,
|
|
||||||
// a pure Go (WASM-based) implementation of TagLib.
|
|
||||||
//
|
|
||||||
// This extractor aims for parity with the CGO-based taglib extractor. It uses
|
|
||||||
// TagLib's PropertyMap interface for standard tags. The File handle API provides
|
|
||||||
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
|
|
||||||
// through a single file open operation.
|
|
||||||
//
|
|
||||||
// This extractor is registered under the name "taglib". It only works with a filesystem
|
|
||||||
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
|
|
||||||
// must implement io.ReadSeeker for go-taglib to read them.
|
|
||||||
package gotaglib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"runtime/debug"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/storage/local"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
|
||||||
"go.senan.xyz/taglib"
|
|
||||||
)
|
|
||||||
|
|
||||||
type extractor struct {
|
|
||||||
fs fs.FS
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
|
||||||
results := make(map[string]metadata.Info)
|
|
||||||
for _, path := range files {
|
|
||||||
props, err := e.extractMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results[path] = *props
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) Version() string {
|
|
||||||
bi, ok := debug.ReadBuildInfo()
|
|
||||||
if ok {
|
|
||||||
for _, dep := range bi.Deps {
|
|
||||||
if dep.Path == "go.senan.xyz/taglib" {
|
|
||||||
if dep.Replace != nil {
|
|
||||||
return dep.Replace.Version
|
|
||||||
}
|
|
||||||
return dep.Version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
|
|
||||||
// Recover from panics in the WASM runtime that can occur during any taglib
|
|
||||||
// operation (opening, reading tags, or reading properties). This catches crashes
|
|
||||||
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
|
|
||||||
// hardened systems with MemoryDenyWriteExecute=true).
|
|
||||||
debug.SetPanicOnFault(true)
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
|
|
||||||
debug.PrintStack()
|
|
||||||
err = fmt.Errorf("WASM runtime panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
f, close, err := e.openFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer close()
|
|
||||||
|
|
||||||
// Get all tags and properties in one go
|
|
||||||
allTags := f.AllTags()
|
|
||||||
props := f.Properties()
|
|
||||||
|
|
||||||
// Map properties to AudioProperties
|
|
||||||
ap := metadata.AudioProperties{
|
|
||||||
Duration: props.Length.Round(time.Millisecond * 10),
|
|
||||||
BitRate: int(props.Bitrate),
|
|
||||||
Channels: int(props.Channels),
|
|
||||||
SampleRate: int(props.SampleRate),
|
|
||||||
BitDepth: int(props.BitsPerSample),
|
|
||||||
Codec: props.Codec,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
|
||||||
normalizedTags := make(map[string][]string, len(allTags.Tags))
|
|
||||||
for key, values := range allTags.Tags {
|
|
||||||
lowerKey := strings.ToLower(key)
|
|
||||||
normalizedTags[lowerKey] = values
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process format-specific raw tags
|
|
||||||
processRawTags(allTags, normalizedTags)
|
|
||||||
|
|
||||||
// Parse track/disc totals from "N/Total" format
|
|
||||||
parseTuple(normalizedTags, "track")
|
|
||||||
parseTuple(normalizedTags, "disc")
|
|
||||||
|
|
||||||
// Adjust some ID3 tags
|
|
||||||
parseLyrics(normalizedTags)
|
|
||||||
parseTIPL(normalizedTags)
|
|
||||||
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
|
|
||||||
|
|
||||||
// Determine if file has embedded picture
|
|
||||||
hasPicture := len(props.Images) > 0
|
|
||||||
|
|
||||||
return &metadata.Info{
|
|
||||||
Tags: normalizedTags,
|
|
||||||
AudioProperties: ap,
|
|
||||||
HasPicture: hasPicture,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFile opens the file at filePath using the extractor's filesystem.
|
|
||||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
|
||||||
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
|
||||||
// Open the file from the filesystem
|
|
||||||
file, err := e.fs.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
rs, isSeekable := file.(io.ReadSeeker)
|
|
||||||
if !isSeekable {
|
|
||||||
file.Close()
|
|
||||||
return nil, nil, errors.New("file is not seekable")
|
|
||||||
}
|
|
||||||
// WithFilename provides a format detection hint via the file extension,
|
|
||||||
// since OpenStream alone relies on content-sniffing which fails for some files.
|
|
||||||
f, err = taglib.OpenStream(rs,
|
|
||||||
taglib.WithReadStyle(taglib.ReadStyleFast),
|
|
||||||
taglib.WithFilename(filePath),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
closeFunc = func() {
|
|
||||||
f.Close()
|
|
||||||
file.Close()
|
|
||||||
}
|
|
||||||
return f, closeFunc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
|
|
||||||
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
|
|
||||||
func parseTuple(tags map[string][]string, prop string) {
|
|
||||||
tagName := prop + "number"
|
|
||||||
tagTotal := prop + "total"
|
|
||||||
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
|
||||||
parts := strings.Split(value[0], "/")
|
|
||||||
tags[tagName] = []string{parts[0]}
|
|
||||||
if len(parts) == 2 {
|
|
||||||
tags[tagTotal] = []string{parts[1]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLyrics ensures lyrics tags have a language code.
|
|
||||||
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
|
|
||||||
func parseLyrics(tags map[string][]string) {
|
|
||||||
lyrics := tags["lyrics"]
|
|
||||||
if len(lyrics) > 0 {
|
|
||||||
tags["lyrics:xxx"] = lyrics
|
|
||||||
delete(tags, "lyrics")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processRawTags processes format-specific raw tags based on the detected file format.
|
|
||||||
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
|
|
||||||
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
|
|
||||||
switch allTags.Format {
|
|
||||||
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
|
|
||||||
parseID3v2Frames(allTags.Raw, normalizedTags)
|
|
||||||
case taglib.FormatMP4:
|
|
||||||
parseMP4Atoms(allTags.Raw, normalizedTags)
|
|
||||||
case taglib.FormatASF:
|
|
||||||
parseASFAttributes(allTags.Raw, normalizedTags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
|
|
||||||
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
|
|
||||||
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
|
|
||||||
// Process frames that have language-specific data
|
|
||||||
for key, values := range rawFrames {
|
|
||||||
lowerKey := strings.ToLower(key)
|
|
||||||
|
|
||||||
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
|
|
||||||
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
|
|
||||||
parts := strings.SplitN(lowerKey, ":", 2)
|
|
||||||
if len(parts) == 2 && parts[1] != "" {
|
|
||||||
lang := parts[1]
|
|
||||||
lyricsKey := "lyrics:" + lang
|
|
||||||
tags[lyricsKey] = append(tags[lyricsKey], values...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
|
|
||||||
for key := range tags {
|
|
||||||
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
|
|
||||||
delete(tags, "lyrics")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iTunesKeyPrefix = "----:com.apple.iTunes:"
|
|
||||||
|
|
||||||
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
|
|
||||||
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
|
|
||||||
// Process all atoms and add them to tags
|
|
||||||
for key, values := range rawAtoms {
|
|
||||||
// Strip iTunes prefix and convert to lowercase
|
|
||||||
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
|
|
||||||
normalizedKey = strings.ToLower(normalizedKey)
|
|
||||||
|
|
||||||
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
|
||||||
if _, exists := tags[normalizedKey]; !exists {
|
|
||||||
tags[normalizedKey] = values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
|
|
||||||
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
|
|
||||||
// Process all attributes and add them to tags
|
|
||||||
for key, values := range rawAttrs {
|
|
||||||
normalizedKey := strings.ToLower(key)
|
|
||||||
|
|
||||||
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
|
||||||
if _, exists := tags[normalizedKey]; !exists {
|
|
||||||
tags[normalizedKey] = values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These are the only roles we support, based on Picard's tag map:
|
|
||||||
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
|
||||||
var tiplMapping = map[string]string{
|
|
||||||
"arranger": "arranger",
|
|
||||||
"engineer": "engineer",
|
|
||||||
"producer": "producer",
|
|
||||||
"mix": "mixer",
|
|
||||||
"DJ-mix": "djmixer",
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
|
||||||
//
|
|
||||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
|
||||||
//
|
|
||||||
// and breaks it down into a map of roles and names, e.g.:
|
|
||||||
//
|
|
||||||
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
|
||||||
func parseTIPL(tags map[string][]string) {
|
|
||||||
tipl := tags["tipl"]
|
|
||||||
if len(tipl) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addRole := func(currentRole string, currentValue []string) {
|
|
||||||
if currentRole != "" && len(currentValue) > 0 {
|
|
||||||
role := tiplMapping[currentRole]
|
|
||||||
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var currentRole string
|
|
||||||
var currentValue []string
|
|
||||||
for part := range strings.SplitSeq(tipl[0], " ") {
|
|
||||||
if _, ok := tiplMapping[part]; ok {
|
|
||||||
addRole(currentRole, currentValue)
|
|
||||||
currentRole = part
|
|
||||||
currentValue = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentValue = append(currentValue, part)
|
|
||||||
}
|
|
||||||
addRole(currentRole, currentValue)
|
|
||||||
delete(tags, "tipl")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ local.Extractor = (*extractor)(nil)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
|
||||||
return &extractor{fsys}
|
|
||||||
})
|
|
||||||
conf.AddHook(func() {
|
|
||||||
log.Debug("go-taglib version", "version", extractor{}.Version())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,731 +0,0 @@
|
||||||
package lastfm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
|
|
||||||
lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("lastfmAgent", func() {
|
|
||||||
var ds model.DataStore
|
|
||||||
var ctx context.Context
|
|
||||||
BeforeEach(func() {
|
|
||||||
ds = &tests.MockDataStore{}
|
|
||||||
ctx = context.Background()
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.LastFM.Enabled = true
|
|
||||||
conf.Server.LastFM.ApiKey = "123"
|
|
||||||
conf.Server.LastFM.Secret = "secret"
|
|
||||||
})
|
|
||||||
Describe("lastFMConstructor", func() {
|
|
||||||
When("Agent is properly configured", func() {
|
|
||||||
It("uses configured api key and languages", func() {
|
|
||||||
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
|
||||||
agent := lastFMConstructor(ds)
|
|
||||||
Expect(agent.apiKey).To(Equal("123"))
|
|
||||||
Expect(agent.secret).To(Equal("secret"))
|
|
||||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
When("Agent is disabled", func() {
|
|
||||||
It("returns nil", func() {
|
|
||||||
conf.Server.LastFM.Enabled = false
|
|
||||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
When("ApiKey is empty", func() {
|
|
||||||
It("returns nil", func() {
|
|
||||||
conf.Server.LastFM.ApiKey = ""
|
|
||||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
When("Secret is empty", func() {
|
|
||||||
It("returns nil", func() {
|
|
||||||
conf.Server.LastFM.Secret = ""
|
|
||||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistBiography", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the biography", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
||||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Language Fallback", func() {
|
|
||||||
Describe("GetArtistBiography", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *langAwareHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = newLangAwareHttpClient()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns content in first language when available (1 API call)", func() {
|
|
||||||
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
|
|
||||||
// Portuguese biography available
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
|
|
||||||
Expect(httpClient.requestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to second language when first returns empty (2 API calls)", func() {
|
|
||||||
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
|
|
||||||
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
|
|
||||||
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
|
||||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
// English returns full biography
|
|
||||||
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
|
|
||||||
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
|
|
||||||
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
|
|
||||||
Expect(httpClient.requestCount).To(Equal(2))
|
|
||||||
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
|
|
||||||
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when all languages return empty", func() {
|
|
||||||
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
|
|
||||||
// Both languages return empty/ignored biography (using actual Last.fm response format)
|
|
||||||
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
|
||||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
// Second language also returns empty
|
|
||||||
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
|
||||||
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(httpClient.requestCount).To(Equal(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetAlbumInfo", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *langAwareHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = newLangAwareHttpClient()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to second language when first returns empty description (2 API calls)", func() {
|
|
||||||
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
|
|
||||||
// Japanese returns album without wiki/description (actual Last.fm response)
|
|
||||||
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
|
||||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
// English returns album with description
|
|
||||||
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
|
|
||||||
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
|
|
||||||
|
|
||||||
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(albumInfo.Name).To(Equal("Dois"))
|
|
||||||
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
|
|
||||||
Expect(httpClient.requestCount).To(Equal(2))
|
|
||||||
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
|
|
||||||
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns album without description when all languages return empty", func() {
|
|
||||||
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
|
|
||||||
// Both languages return album without description
|
|
||||||
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
|
||||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
|
||||||
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
|
|
||||||
|
|
||||||
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(albumInfo.Name).To(Equal("Dois"))
|
|
||||||
Expect(albumInfo.Description).To(BeEmpty())
|
|
||||||
Expect(httpClient.requestCount).To(Equal(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetSimilarArtists", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns similar artists", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
|
|
||||||
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
|
|
||||||
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
||||||
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistTopSongs", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns top songs", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
|
|
||||||
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
|
|
||||||
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
||||||
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetSimilarSongsByTrack", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns similar songs", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
|
|
||||||
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
|
||||||
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
|
||||||
{Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}},
|
|
||||||
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
|
|
||||||
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when no similar songs found", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Scrobbling", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var track *model.MediaFile
|
|
||||||
BeforeEach(func() {
|
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
track = &model.MediaFile{
|
|
||||||
ID: "123",
|
|
||||||
Title: "Track Title",
|
|
||||||
Album: "Track Album",
|
|
||||||
Artist: "Track Artist",
|
|
||||||
AlbumArtist: "Track AlbumArtist",
|
|
||||||
TrackNumber: 1,
|
|
||||||
Duration: 180,
|
|
||||||
MbzRecordingID: "mbz-123",
|
|
||||||
Participants: map[model.Role]model.ParticipantList{
|
|
||||||
model.RoleArtist: []model.Participant{
|
|
||||||
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
|
|
||||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
|
|
||||||
},
|
|
||||||
model.RoleAlbumArtist: []model.Participant{
|
|
||||||
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
|
|
||||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("NowPlaying", func() {
|
|
||||||
It("calls Last.fm with correct params", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
sentParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
|
||||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
|
||||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
|
||||||
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
|
||||||
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
|
||||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
|
||||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
|
||||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
|
||||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
||||||
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
When("ScrobbleFirstArtistOnly is true", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses only the first artist", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
sentParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
|
||||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("scrobble", func() {
|
|
||||||
It("calls Last.fm with correct params", func() {
|
|
||||||
ts := time.Now()
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
sentParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
|
||||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
|
||||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
|
||||||
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
|
||||||
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
|
||||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
|
||||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
|
||||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
|
||||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
|
||||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
|
||||||
})
|
|
||||||
|
|
||||||
When("ScrobbleFirstArtistOnly is true", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses only the first artist", func() {
|
|
||||||
ts := time.Now()
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
sentParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
|
||||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips songs with less than 31 seconds", func() {
|
|
||||||
track.Duration = 29
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
||||||
err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 11", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 16", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on http errors", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrUnrecoverable on other errors", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetAlbumInfo", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the biography", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
|
|
||||||
Name: "Believe",
|
|
||||||
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
|
|
||||||
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
|
|
||||||
URL: "https://www.last.fm/music/Cher/Believe",
|
|
||||||
}))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty images if no images are available", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
|
|
||||||
Name: "The Definitive Less Damage And More Joy",
|
|
||||||
URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
|
|
||||||
}))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error if Last.fm call returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
|
||||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
|
||||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
|
|
||||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistImages", func() {
|
|
||||||
var agent *lastfmAgent
|
|
||||||
var apiClient *tests.FakeHttpClient
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
apiClient = &tests.FakeHttpClient{}
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("API_KEY", "SECRET", apiClient)
|
|
||||||
agent = lastFMConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
agent.httpClient = httpClient
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the artist image from the page", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(images).To(HaveLen(1))
|
|
||||||
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
|
|
||||||
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
|
|
||||||
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
|
|
||||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty list if image is the ignored default image", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(images).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors when the page has no meta tags", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors when Last.fm serves a bot challenge page", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
|
||||||
Expect(images).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not park the agent: the failure is not a retry-later", func() {
|
|
||||||
// A RetryLaterError would cool down the agent's API-backed methods too.
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error if API call fails", func() {
|
|
||||||
apiClient.Err = errors.New("api error")
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("get artist info"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error if scraper call fails", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
httpClient.Err = errors.New("scraper error")
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("get artist url"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
|
|
||||||
type langAwareHttpClient struct {
|
|
||||||
responses map[string]http.Response
|
|
||||||
requests []*http.Request
|
|
||||||
requestCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLangAwareHttpClient() *langAwareHttpClient {
|
|
||||||
return &langAwareHttpClient{
|
|
||||||
responses: make(map[string]http.Response),
|
|
||||||
requests: make([]*http.Request, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
|
|
||||||
c.requestCount++
|
|
||||||
c.requests = append(c.requests, req)
|
|
||||||
lang := req.URL.Query().Get("lang")
|
|
||||||
if resp, ok := c.responses[lang]; ok {
|
|
||||||
return &resp, nil
|
|
||||||
}
|
|
||||||
// Return default empty response if no specific response is configured
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
package lastfm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("auth_router", func() {
|
|
||||||
var (
|
|
||||||
ds *tests.MockDataStore
|
|
||||||
userProps *tests.MockedUserPropsRepo
|
|
||||||
httpClient *tests.FakeHttpClient
|
|
||||||
router *Router
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
victimID = "victim-user-id"
|
|
||||||
attackerID = "attacker-user-id"
|
|
||||||
)
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
userProps = &tests.MockedUserPropsRepo{}
|
|
||||||
ds = &tests.MockDataStore{
|
|
||||||
MockedProperty: &tests.MockedPropertyRepo{},
|
|
||||||
MockedUserProps: userProps,
|
|
||||||
}
|
|
||||||
auth.Init(ds)
|
|
||||||
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
router = &Router{
|
|
||||||
ds: ds,
|
|
||||||
apiKey: "API_KEY",
|
|
||||||
secret: "SECRET",
|
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
|
||||||
}
|
|
||||||
router.client = newClient(router.apiKey, router.secret, httpClient)
|
|
||||||
router.Handler = router.routes()
|
|
||||||
})
|
|
||||||
|
|
||||||
storedSessionKey := func(userID string) string {
|
|
||||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
stubGetSessionOK := func(sessionKey string) {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Describe("getLinkStatus", func() {
|
|
||||||
It("includes a signed linkToken for the authenticated user", func() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link", nil)
|
|
||||||
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.getLinkStatus(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
||||||
var body map[string]any
|
|
||||||
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
|
|
||||||
Expect(body["apiKey"]).To(Equal("API_KEY"))
|
|
||||||
Expect(body["status"]).To(Equal(false))
|
|
||||||
token, ok := body["linkToken"].(string)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(token).ToNot(BeEmpty())
|
|
||||||
|
|
||||||
verified, err := verifyLinkToken(token)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(verified).To(Equal(victimID))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("callback", func() {
|
|
||||||
It("stores the session key under the user encoded in the signed token", func() {
|
|
||||||
stubGetSessionOK("LEGIT_SESSION")
|
|
||||||
linkToken, err := createLinkToken(victimID)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a raw (unsigned) uid value", func() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
|
||||||
Expect(httpClient.SavedRequest).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects an expired link token", func() {
|
|
||||||
expiredToken, err := auth.EncodeToken(map[string]any{
|
|
||||||
"uid": victimID,
|
|
||||||
"scope": linkTokenScope,
|
|
||||||
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
|
|
||||||
})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
|
||||||
Expect(httpClient.SavedRequest).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
|
|
||||||
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(storedSessionKey(attackerID)).To(BeEmpty())
|
|
||||||
Expect(httpClient.SavedRequest).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
|
|
||||||
// An attacker holds a legitimate link token for their own account.
|
|
||||||
// They attempt to call the callback hoping to overwrite the victim's
|
|
||||||
// session key — but the handler must derive the user ID from the
|
|
||||||
// signed token, not from any other input.
|
|
||||||
stubGetSessionOK("ATTACKER_SESSION")
|
|
||||||
attackerToken, err := createLinkToken(attackerID)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
|
|
||||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns 400 when uid is missing", func() {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns 400 when token is missing", func() {
|
|
||||||
linkToken, err := createLinkToken(victimID)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.callback(rec, req)
|
|
||||||
|
|
||||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("link token helpers", func() {
|
|
||||||
It("round-trips a freshly issued token", func() {
|
|
||||||
token, err := createLinkToken(victimID)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
uid, err := verifyLinkToken(token)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(uid).To(Equal(victimID))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects garbage", func() {
|
|
||||||
_, err := verifyLinkToken("not-a-jwt")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a token whose scope claim is wrong", func() {
|
|
||||||
wrongScopeToken, err := auth.EncodeToken(map[string]any{
|
|
||||||
"uid": victimID,
|
|
||||||
"scope": "some-other-scope",
|
|
||||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
|
||||||
})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
_, err = verifyLinkToken(wrongScopeToken)
|
|
||||||
Expect(err).To(MatchError("invalid link token scope"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a scoped token that has no expiration", func() {
|
|
||||||
nonExpiringToken, err := auth.EncodeToken(map[string]any{
|
|
||||||
"uid": victimID,
|
|
||||||
"scope": linkTokenScope,
|
|
||||||
})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
_, err = verifyLinkToken(nonExpiringToken)
|
|
||||||
Expect(err).To(MatchError("link token missing expiration"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a Jellyfin access token", func() {
|
|
||||||
usr := &model.User{ID: "u1", UserName: "johndoe"}
|
|
||||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
_, err = verifyLinkToken(tokenStr)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
package lastfm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/md5"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("client", func() {
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var client *client
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("albumGetInfo", func() {
|
|
||||||
It("returns an album on successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(album.Name).To(Equal("Believe"))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not double-encode plus signs", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("artistGetInfo", func() {
|
|
||||||
It("returns an artist for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(artist.Name).To(Equal("U2"))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an http status != 200", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError("last.fm http status: (500)"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an http status != 200", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if HttpClient.Do() returns error", func() {
|
|
||||||
httpClient.Err = errors.New("generic error")
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError("generic error"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if returned body is not a valid JSON", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`<xml>NOT_VALID_JSON</xml>`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("artistGetSimilar", func() {
|
|
||||||
It("returns an artist for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(len(similar.Artists)).To(Equal(2))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("artistGetTopTracks", func() {
|
|
||||||
It("returns top tracks for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(len(top.Track)).To(Equal(2))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("trackGetSimilar", func() {
|
|
||||||
It("returns similar tracks for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(len(similar.Track)).To(Equal(5))
|
|
||||||
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
|
|
||||||
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
|
|
||||||
Expect(similar.Track[0].Match).To(Equal(1.0))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the track and artist names", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
query := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(query.Get("track")).To(Equal("1%2B1"))
|
|
||||||
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty list when no similar tracks found", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(similar.Track).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetToken", func() {
|
|
||||||
It("returns a token when the request is successful", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
|
|
||||||
queryParams := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
|
|
||||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
|
||||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("getSession", func() {
|
|
||||||
It("returns a session key when the request is successful", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
|
|
||||||
queryParams := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
|
|
||||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
|
||||||
Expect(queryParams.Get("token")).To(Equal("TOKEN"))
|
|
||||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("scrobble", func() {
|
|
||||||
It("sends parameters in request body for POST", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ScrobbleInfo{
|
|
||||||
artist: "U2",
|
|
||||||
track: "One",
|
|
||||||
album: "Achtung Baby",
|
|
||||||
trackNumber: 1,
|
|
||||||
duration: 276,
|
|
||||||
albumArtist: "U2",
|
|
||||||
}
|
|
||||||
err := client.scrobble(context.Background(), "SESSION_KEY", info)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
req := httpClient.SavedRequest
|
|
||||||
Expect(req.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
|
|
||||||
Expect(req.URL.RawQuery).To(BeEmpty())
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(req.Body)
|
|
||||||
bodyParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
|
|
||||||
Expect(bodyParams.Get("artist")).To(Equal("U2"))
|
|
||||||
Expect(bodyParams.Get("track")).To(Equal("One"))
|
|
||||||
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("updateNowPlaying", func() {
|
|
||||||
It("sends parameters in request body for POST", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ScrobbleInfo{
|
|
||||||
artist: "U2",
|
|
||||||
track: "One",
|
|
||||||
album: "Achtung Baby",
|
|
||||||
trackNumber: 1,
|
|
||||||
duration: 276,
|
|
||||||
albumArtist: "U2",
|
|
||||||
}
|
|
||||||
err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
req := httpClient.SavedRequest
|
|
||||||
Expect(req.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
|
|
||||||
Expect(req.URL.RawQuery).To(BeEmpty())
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(req.Body)
|
|
||||||
bodyParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
|
||||||
Expect(bodyParams.Get("artist")).To(Equal("U2"))
|
|
||||||
Expect(bodyParams.Get("track")).To(Equal("One"))
|
|
||||||
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("sign", func() {
|
|
||||||
It("adds an api_sig param with the signature", func() {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Add("d", "444")
|
|
||||||
params.Add("callback", "https://myserver.com")
|
|
||||||
params.Add("a", "111")
|
|
||||||
params.Add("format", "json")
|
|
||||||
params.Add("c", "333")
|
|
||||||
params.Add("b", "222")
|
|
||||||
client.sign(params)
|
|
||||||
Expect(params).To(HaveKey("api_sig"))
|
|
||||||
sig := params.Get("api_sig")
|
|
||||||
expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
|
|
||||||
Expect(sig).To(Equal(expected))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package lastfm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
linkTokenScope = "lastfm-link"
|
|
||||||
linkTokenTTL = 5 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// createLinkToken issues a signed token binding the Last.fm callback to the
|
|
||||||
// user who initiated the OAuth flow. It travels back through Last.fm via the
|
|
||||||
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
|
|
||||||
func createLinkToken(userID string) (string, error) {
|
|
||||||
claims := map[string]any{
|
|
||||||
"uid": userID,
|
|
||||||
"scope": linkTokenScope,
|
|
||||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
|
||||||
}
|
|
||||||
return auth.EncodeToken(claims)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyLinkToken validates a signed link token and returns the encoded user ID.
|
|
||||||
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
|
|
||||||
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
|
|
||||||
// regular session JWT) from being accepted here.
|
|
||||||
func verifyLinkToken(tokenStr string) (string, error) {
|
|
||||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// jwtauth treats a token without `exp` as non-expiring; require it
|
|
||||||
// explicitly so an accidental regression cannot mint permanent tokens.
|
|
||||||
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
|
|
||||||
return "", errors.New("link token missing expiration")
|
|
||||||
}
|
|
||||||
var scope string
|
|
||||||
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
|
|
||||||
return "", errors.New("invalid link token scope")
|
|
||||||
}
|
|
||||||
var uid string
|
|
||||||
if err := token.Get("uid", &uid); err != nil || uid == "" {
|
|
||||||
return "", errors.New("invalid link token user ID")
|
|
||||||
}
|
|
||||||
return uid, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
listenBrainzAgentName = "listenbrainz"
|
|
||||||
sessionKeyProperty = "ListenBrainzSessionKey"
|
|
||||||
)
|
|
||||||
|
|
||||||
type listenBrainzAgent struct {
|
|
||||||
ds model.DataStore
|
|
||||||
sessionKeys *agents.SessionKeys
|
|
||||||
baseURL string
|
|
||||||
client *client
|
|
||||||
}
|
|
||||||
|
|
||||||
func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
|
||||||
l := &listenBrainzAgent{
|
|
||||||
ds: ds,
|
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
|
||||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
|
||||||
}
|
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
|
||||||
l.client = newClient(l.baseURL, chc)
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) AgentName() string {
|
|
||||||
return listenBrainzAgentName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
|
||||||
artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
|
|
||||||
return p.MbzArtistID
|
|
||||||
})
|
|
||||||
artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
|
|
||||||
return p.Name
|
|
||||||
})
|
|
||||||
li := listenInfo{
|
|
||||||
TrackMetadata: trackMetadata{
|
|
||||||
ArtistName: track.Artist,
|
|
||||||
TrackName: track.Title,
|
|
||||||
ReleaseName: track.Album,
|
|
||||||
AdditionalInfo: additionalInfo{
|
|
||||||
SubmissionClient: consts.AppName,
|
|
||||||
SubmissionClientVersion: consts.Version,
|
|
||||||
TrackNumber: track.TrackNumber,
|
|
||||||
ArtistNames: artistNames,
|
|
||||||
ArtistMBIDs: artistMBIDs,
|
|
||||||
RecordingMBID: track.MbzRecordingID,
|
|
||||||
ReleaseMBID: track.MbzAlbumID,
|
|
||||||
ReleaseGroupMBID: track.MbzReleaseGroupID,
|
|
||||||
DurationMs: int(track.Duration * 1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return li
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
|
||||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
|
||||||
if err != nil || sk == "" {
|
|
||||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
li := l.formatListen(track)
|
|
||||||
err = l.client.updateNowPlaying(ctx, sk, li)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
|
|
||||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
|
||||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
|
||||||
if err != nil || sk == "" {
|
|
||||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
li := l.formatListen(&s.MediaFile)
|
|
||||||
li.ListenedAt = int(s.TimeStamp.Unix())
|
|
||||||
err = l.client.scrobble(ctx, sk, li)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var lbErr *listenBrainzError
|
|
||||||
isListenBrainzError := errors.As(err, &lbErr)
|
|
||||||
if !isListenBrainzError {
|
|
||||||
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
|
|
||||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
|
||||||
}
|
|
||||||
if lbErr.Code == 500 || lbErr.Code == 503 {
|
|
||||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
|
||||||
}
|
|
||||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
|
||||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
|
||||||
return err == nil && sk != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
||||||
if mbid == "" {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
url, err := l.client.getArtistUrl(ctx, mbid)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
|
||||||
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(resp) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
res := make([]agents.Song, len(resp))
|
|
||||||
for i, t := range resp {
|
|
||||||
res[i] = agents.Song{
|
|
||||||
Album: t.ReleaseName,
|
|
||||||
AlbumMBID: t.ReleaseMBID,
|
|
||||||
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
|
|
||||||
Duration: t.DurationMs,
|
|
||||||
Name: t.RecordingName,
|
|
||||||
MBID: t.RecordingMbid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// topSongArtists maps the top-recordings response, which carries a single combined display name
|
|
||||||
// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not
|
|
||||||
// positionally pairable, so the display name attaches to the first credit and any further MBIDs
|
|
||||||
// become MBID-only collaborators — still valid identity signals for the matcher.
|
|
||||||
func topSongArtists(name string, mbids []string) []agents.Artist {
|
|
||||||
if len(mbids) == 0 {
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []agents.Artist{{Name: name}}
|
|
||||||
}
|
|
||||||
artists := make([]agents.Artist, len(mbids))
|
|
||||||
artists[0] = agents.Artist{Name: name, MBID: mbids[0]}
|
|
||||||
for i, m := range mbids[1:] {
|
|
||||||
artists[i+1] = agents.Artist{MBID: m}
|
|
||||||
}
|
|
||||||
return artists
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
|
|
||||||
if mbid == "" {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
artists := make([]agents.Artist, len(resp))
|
|
||||||
for i, artist := range resp {
|
|
||||||
artists[i] = agents.Artist{
|
|
||||||
MBID: artist.MBID,
|
|
||||||
Name: artist.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return artists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
|
|
||||||
if mbid == "" {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
songs := make([]agents.Song, len(resp))
|
|
||||||
for i, song := range resp {
|
|
||||||
songs[i] = agents.Song{
|
|
||||||
Album: song.ReleaseName,
|
|
||||||
AlbumMBID: song.ReleaseMBID,
|
|
||||||
Artists: []agents.Artist{{Name: song.Artist}},
|
|
||||||
MBID: song.MBID,
|
|
||||||
Name: song.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return songs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
conf.AddHook(func() {
|
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
|
||||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
|
||||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
|
||||||
// See https://go.dev/doc/faq#nil_error
|
|
||||||
a := listenBrainzConstructor(ds)
|
|
||||||
if a != nil {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
|
|
||||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
|
||||||
// See https://go.dev/doc/faq#nil_error
|
|
||||||
a := listenBrainzConstructor(ds)
|
|
||||||
if a != nil {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
)
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
. "github.com/onsi/gomega/gstruct"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("listenBrainzAgent", func() {
|
|
||||||
var ds model.DataStore
|
|
||||||
var ctx context.Context
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var track *model.MediaFile
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ds = &tests.MockDataStore{}
|
|
||||||
ctx = context.Background()
|
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
|
||||||
track = &model.MediaFile{
|
|
||||||
ID: "123",
|
|
||||||
Title: "Track Title",
|
|
||||||
Album: "Track Album",
|
|
||||||
Artist: "Track Artist",
|
|
||||||
TrackNumber: 1,
|
|
||||||
MbzRecordingID: "mbz-123",
|
|
||||||
MbzAlbumID: "mbz-456",
|
|
||||||
MbzReleaseGroupID: "mbz-789",
|
|
||||||
Duration: 142.2,
|
|
||||||
Participants: map[model.Role]model.ParticipantList{
|
|
||||||
model.RoleArtist: []model.Participant{
|
|
||||||
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
|
|
||||||
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("formatListen", func() {
|
|
||||||
It("constructs the listenInfo properly", func() {
|
|
||||||
lr := agent.formatListen(track)
|
|
||||||
Expect(lr).To(MatchAllFields(Fields{
|
|
||||||
"ListenedAt": Equal(0),
|
|
||||||
"TrackMetadata": MatchAllFields(Fields{
|
|
||||||
"ArtistName": Equal(track.Artist),
|
|
||||||
"TrackName": Equal(track.Title),
|
|
||||||
"ReleaseName": Equal(track.Album),
|
|
||||||
"AdditionalInfo": MatchAllFields(Fields{
|
|
||||||
"SubmissionClient": Equal(consts.AppName),
|
|
||||||
"SubmissionClientVersion": Equal(consts.Version),
|
|
||||||
"TrackNumber": Equal(track.TrackNumber),
|
|
||||||
"RecordingMBID": Equal(track.MbzRecordingID),
|
|
||||||
"ReleaseMBID": Equal(track.MbzAlbumID),
|
|
||||||
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
|
|
||||||
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
|
|
||||||
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
|
|
||||||
"DurationMs": Equal(142200),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("NowPlaying", func() {
|
|
||||||
It("updates NowPlaying successfully", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
||||||
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
|
||||||
var sc scrobbler.Scrobble
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sends a Scrobble successfully", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sets the Timestamp properly", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
|
|
||||||
var lr listenBrainzRequestBody
|
|
||||||
err = decoder.Decode(&lr)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
||||||
err := agent.Scrobble(ctx, "user-2", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 503", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
|
|
||||||
StatusCode: 503,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 500", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on http errors", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrUnrecoverable on other errors", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps a 429 scrobble for retry and carries the delay", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistUrl", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns artist url when MBID present", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when url not present", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when ListenBrainz returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetTopSongs", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error on listenbrainz error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns all tracks when asked", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "world.execute(me);",
|
|
||||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
|
||||||
Album: "Miracle Milk",
|
|
||||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
Duration: 211912,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "String Theocracy",
|
|
||||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
|
||||||
Album: "String Theocracy",
|
|
||||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
|
||||||
Duration: 174000,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns only one track when prompted", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "world.execute(me);",
|
|
||||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
|
||||||
Album: "Miracle Milk",
|
|
||||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
Duration: 211912,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() {
|
|
||||||
body := `[{
|
|
||||||
"recording_name": "Collab",
|
|
||||||
"recording_mbid": "rec-1",
|
|
||||||
"artist_name": "Drake feat. Future",
|
|
||||||
"artist_mbids": ["mbid-drake", "mbid-future"],
|
|
||||||
"release_name": "Album",
|
|
||||||
"release_mbid": "rel-1",
|
|
||||||
"length": 200000
|
|
||||||
}]`
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(HaveLen(1))
|
|
||||||
Expect(data[0].Artists).To(Equal([]agents.Artist{
|
|
||||||
{Name: "Drake feat. Future", MBID: "mbid-drake"},
|
|
||||||
{MBID: "mbid-future"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves Artists nil when the top song carries no name or MBIDs", func() {
|
|
||||||
body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]`
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(HaveLen(1))
|
|
||||||
Expect(data[0].Artists).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetSimilarArtists", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
|
|
||||||
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error on listenbrainz error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns all data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
|
|
||||||
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns subset of data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetSimilarTracks", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
|
|
||||||
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error on listenbrainz error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns all data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "Take On Me",
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
ISRC: "",
|
|
||||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
|
||||||
Album: "Hunting High and Low",
|
|
||||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Duration: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
|
||||||
ISRC: "",
|
|
||||||
Artists: []agents.Artist{{Name: "Wham!"}},
|
|
||||||
Album: "Make It Big",
|
|
||||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
|
||||||
Duration: 0,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns subset of data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "Take On Me",
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
ISRC: "",
|
|
||||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
|
||||||
Album: "Hunting High and Low",
|
|
||||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Duration: 0,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,395 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
lbzApiUrl = "https://api.listenbrainz.org/1/"
|
|
||||||
labsBase = "https://labs.api.listenbrainz.org/"
|
|
||||||
)
|
|
||||||
|
|
||||||
// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In
|
|
||||||
// (delta-seconds) on every response, including the 429, and never Retry-After.
|
|
||||||
func retryLaterErr(h http.Header) *agents.RetryLaterError {
|
|
||||||
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrorNotFound = errors.New("listenbrainz: not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
type listenBrainzError struct {
|
|
||||||
Code int
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *listenBrainzError) Error() string {
|
|
||||||
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
type httpDoer interface {
|
|
||||||
Do(req *http.Request) (*http.Response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClient(baseURL string, hc httpDoer) *client {
|
|
||||||
return &client{baseURL, hc}
|
|
||||||
}
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
baseURL string
|
|
||||||
hc httpDoer
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenBrainzResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Valid bool `json:"valid"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenBrainzRequest struct {
|
|
||||||
ApiKey string //nolint:gosec
|
|
||||||
Body listenBrainzRequestBody
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenBrainzRequestBody struct {
|
|
||||||
ListenType listenType `json:"listen_type,omitempty"`
|
|
||||||
Payload []listenInfo `json:"payload,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Single listenType = "single"
|
|
||||||
PlayingNow listenType = "playing_now"
|
|
||||||
)
|
|
||||||
|
|
||||||
type listenInfo struct {
|
|
||||||
ListenedAt int `json:"listened_at,omitempty"`
|
|
||||||
TrackMetadata trackMetadata `json:"track_metadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type trackMetadata struct {
|
|
||||||
ArtistName string `json:"artist_name,omitempty"`
|
|
||||||
TrackName string `json:"track_name,omitempty"`
|
|
||||||
ReleaseName string `json:"release_name,omitempty"`
|
|
||||||
AdditionalInfo additionalInfo `json:"additional_info"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type additionalInfo struct {
|
|
||||||
SubmissionClient string `json:"submission_client,omitempty"`
|
|
||||||
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
|
|
||||||
TrackNumber int `json:"tracknumber,omitempty"`
|
|
||||||
ArtistNames []string `json:"artist_names,omitempty"`
|
|
||||||
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
|
|
||||||
RecordingMBID string `json:"recording_mbid,omitempty"`
|
|
||||||
ReleaseMBID string `json:"release_mbid,omitempty"`
|
|
||||||
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
|
|
||||||
DurationMs int `json:"duration_ms,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
|
|
||||||
r := &listenBrainzRequest{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
}
|
|
||||||
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
|
|
||||||
r := &listenBrainzRequest{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
Body: listenBrainzRequestBody{
|
|
||||||
ListenType: PlayingNow,
|
|
||||||
Payload: []listenInfo{li},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.Status != "ok" {
|
|
||||||
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
|
|
||||||
r := &listenBrainzRequest{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
Body: listenBrainzRequestBody{
|
|
||||||
ListenType: Single,
|
|
||||||
Payload: []listenInfo{li},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.Status != "ok" {
|
|
||||||
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) path(endpoint string) (string, error) {
|
|
||||||
u, err := url.Parse(c.baseURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
u.Path = path.Join(u.Path, endpoint)
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
|
|
||||||
b, _ := json.Marshal(r.Body)
|
|
||||||
uri, err := c.path(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
|
|
||||||
if r.ApiKey != "" {
|
|
||||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return nil, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var response listenBrainzResponse
|
|
||||||
jsonErr := decoder.Decode(&response)
|
|
||||||
if resp.StatusCode != 200 && jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, jsonErr
|
|
||||||
}
|
|
||||||
if response.Code != 0 && response.Code != 200 {
|
|
||||||
// LB also reports rate limiting as a body code, not only as an HTTP status.
|
|
||||||
if response.Code == http.StatusTooManyRequests {
|
|
||||||
return &response, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type lbzHttpError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
req.URL.RawQuery = params.Encode()
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// On a 200 code, there is no code. Decode using using error message if it exists
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return nil, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var lbzError lbzHttpError
|
|
||||||
jsonErr := decoder.Decode(&lbzError)
|
|
||||||
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistMetadataResult struct {
|
|
||||||
Rels struct {
|
|
||||||
OfficialHomepage string `json:"official homepage,omitempty"`
|
|
||||||
} `json:"rels,omitzero"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Add("artist_mbids", mbid)
|
|
||||||
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var response []artistMetadataResult
|
|
||||||
jsonErr := decoder.Decode(&response)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
|
|
||||||
return "", ErrorNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return response[0].Rels.OfficialHomepage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type trackInfo struct {
|
|
||||||
ArtistName string `json:"artist_name"`
|
|
||||||
ArtistMBIDs []string `json:"artist_mbids"`
|
|
||||||
DurationMs uint32 `json:"length"`
|
|
||||||
RecordingName string `json:"recording_name"`
|
|
||||||
RecordingMbid string `json:"recording_mbid"`
|
|
||||||
ReleaseName string `json:"release_name"`
|
|
||||||
ReleaseMBID string `json:"release_mbid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
|
|
||||||
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var response []trackInfo
|
|
||||||
jsonErr := decoder.Decode(&response)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(response) > count {
|
|
||||||
return response[0:count], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type artist struct {
|
|
||||||
MBID string `json:"artist_mbid"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
req.URL.RawQuery = url.Values{
|
|
||||||
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
|
|
||||||
}.Encode()
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var artists []artist
|
|
||||||
jsonErr := decoder.Decode(&artists)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(artists) > limit {
|
|
||||||
return artists[:limit], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return artists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type recording struct {
|
|
||||||
MBID string `json:"recording_mbid"`
|
|
||||||
Name string `json:"recording_name"`
|
|
||||||
Artist string `json:"artist_credit_name"`
|
|
||||||
ReleaseName string `json:"release_name"`
|
|
||||||
ReleaseMBID string `json:"release_mbid"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
req.URL.RawQuery = url.Values{
|
|
||||||
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
|
|
||||||
}.Encode()
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var recordings []recording
|
|
||||||
jsonErr := decoder.Decode(&recordings)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For whatever reason, labs API isn't guaranteed to give results in the proper order
|
|
||||||
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
|
|
||||||
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
|
|
||||||
slices.SortFunc(recordings, func(a, b recording) int {
|
|
||||||
return cmp.Or(
|
|
||||||
cmp.Compare(b.Score, a.Score), // Sort by score descending
|
|
||||||
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
|
|
||||||
return a.MBID == b.MBID
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(recordings) > limit {
|
|
||||||
return recordings[:limit], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return recordings, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,537 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("client", func() {
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var client *client
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client = newClient("BASE_URL/", httpClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("listenBrainzResponse", func() {
|
|
||||||
It("parses a response properly", func() {
|
|
||||||
var response listenBrainzResponse
|
|
||||||
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(response.Code).To(Equal(200))
|
|
||||||
Expect(response.Message).To(Equal("Message"))
|
|
||||||
Expect(response.UserName).To(Equal("UserName"))
|
|
||||||
Expect(response.Valid).To(BeTrue())
|
|
||||||
Expect(response.Status).To(Equal("ok"))
|
|
||||||
Expect(response.Error).To(Equal("Error"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("validateToken", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("formats the request properly", func() {
|
|
||||||
_, err := client.validateToken(context.Background(), "LB-TOKEN")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses and returns the response", func() {
|
|
||||||
res, err := client.validateToken(context.Background(), "LB-TOKEN")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(res.Valid).To(Equal(true))
|
|
||||||
Expect(res.UserName).To(Equal("ListenBrainzUser"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with listenInfo", func() {
|
|
||||||
var li listenInfo
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
li = listenInfo{
|
|
||||||
TrackMetadata: trackMetadata{
|
|
||||||
ArtistName: "Track Artist",
|
|
||||||
TrackName: "Track Title",
|
|
||||||
ReleaseName: "Track Album",
|
|
||||||
AdditionalInfo: additionalInfo{
|
|
||||||
TrackNumber: 1,
|
|
||||||
ArtistNames: []string{"Artist 1", "Artist 2"},
|
|
||||||
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
|
|
||||||
RecordingMBID: "mbz-123",
|
|
||||||
ReleaseMBID: "mbz-456",
|
|
||||||
DurationMs: 142200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("updateNowPlaying", func() {
|
|
||||||
It("formats the request properly", func() {
|
|
||||||
Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
|
|
||||||
Expect(body).To(MatchJSON(f))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("scrobble", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
li.ListenedAt = 1635000000
|
|
||||||
})
|
|
||||||
|
|
||||||
It("formats the request properly", func() {
|
|
||||||
Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
|
|
||||||
Expect(body).To(MatchJSON(f))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getArtistUrl", func() {
|
|
||||||
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles a malformed request without meaningful body", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(``)),
|
|
||||||
StatusCode: 501,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns not found when the artist has no official homepage", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err.Error()).To(Equal("listenbrainz: not found"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns data when the artist has a homepage", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(url).To(Equal("http://projectmili.com/"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getArtistTopSongs", func() {
|
|
||||||
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
|
|
||||||
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles a malformed request without standard body", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(``)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns all tracks when given the opportunity", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]trackInfo{
|
|
||||||
{
|
|
||||||
ArtistName: "Mili",
|
|
||||||
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
|
|
||||||
DurationMs: 211912,
|
|
||||||
RecordingName: "world.execute(me);",
|
|
||||||
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
ReleaseName: "Miracle Milk",
|
|
||||||
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ArtistName: "Mili",
|
|
||||||
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
|
|
||||||
DurationMs: 174000,
|
|
||||||
RecordingName: "String Theocracy",
|
|
||||||
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
|
||||||
ReleaseName: "String Theocracy",
|
|
||||||
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns a subset of tracks when allowed", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]trackInfo{
|
|
||||||
{
|
|
||||||
ArtistName: "Mili",
|
|
||||||
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
|
|
||||||
DurationMs: 211912,
|
|
||||||
RecordingName: "world.execute(me);",
|
|
||||||
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
ReleaseName: "Miracle Milk",
|
|
||||||
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getSimilarArtists", func() {
|
|
||||||
var algorithm string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
|
||||||
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
|
|
||||||
}
|
|
||||||
|
|
||||||
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
|
|
||||||
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getSimilarArtists(context.Background(), "1", 2)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles real data properly", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
|
|
||||||
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("truncates data when requested", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fetches a different endpoint when algorithm changes", func() {
|
|
||||||
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
|
||||||
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
|
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
|
|
||||||
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getSimilarRecordings", func() {
|
|
||||||
var algorithm string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
|
||||||
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
|
|
||||||
}
|
|
||||||
|
|
||||||
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
|
|
||||||
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles real data properly", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
|
||||||
Artist: "Wham!",
|
|
||||||
ReleaseName: "Make It Big",
|
|
||||||
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
|
|
||||||
Score: 65,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("truncates data when requested", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("properly sorts by score and truncates duplicates", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
// There are actually 5 items. The dedup should happen FIRST
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
|
|
||||||
Name: "Everybody Wants to Rule the World",
|
|
||||||
Artist: "Tears for Fears",
|
|
||||||
ReleaseName: "Songs From the Big Chair",
|
|
||||||
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
|
|
||||||
Score: 68,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
|
||||||
Artist: "Wham!",
|
|
||||||
ReleaseName: "Make It Big",
|
|
||||||
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
|
|
||||||
Score: 65,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
|
|
||||||
Name: "Tainted Love",
|
|
||||||
Artist: "Soft Cell",
|
|
||||||
ReleaseName: "Non-Stop Erotic Cabaret",
|
|
||||||
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
|
|
||||||
Score: 61,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses a different algorithm when configured", func() {
|
|
||||||
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
|
||||||
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
|
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("rate limiting", func() {
|
|
||||||
It("returns RetryLaterError with the header delay on 429", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(3 * time.Second))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns RetryLaterError with zero delay when no header is present", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(retry.RetryIn).To(BeZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("caps absurd header values at one hour",
|
|
||||||
func(header string) {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(retry.RetryIn).To(Equal(time.Hour))
|
|
||||||
},
|
|
||||||
Entry("a large value", "999999"),
|
|
||||||
Entry("a huge value", "99999999999"),
|
|
||||||
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
|
|
||||||
Entry("a value that overflows int64 nanoseconds", "18446744074"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("maps a body-level 429 sent with a non-429 status", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(5 * time.Second))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package gotaglib
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/djherbis/times"
|
"github.com/djherbis/times"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -90,11 +91,12 @@ var _ = Describe("Extractor", func() {
|
||||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
metadata := metadata.New(path, info)
|
metadata := metadata.New(path, info)
|
||||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
mf := metadata.ToMediaFile(1, "folderID")
|
||||||
|
return &mf
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
e = &extractor{fs: os.DirFS(".")}
|
e = &extractor{}
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ReplayGain", func() {
|
Describe("ReplayGain", func() {
|
||||||
|
|
@ -107,7 +109,7 @@ var _ = Describe("Extractor", func() {
|
||||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||||
},
|
},
|
||||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
|
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -118,8 +120,8 @@ var _ = Describe("Extractor", func() {
|
||||||
DisplayTitle: "",
|
DisplayTitle: "",
|
||||||
Lang: code,
|
Lang: code,
|
||||||
Line: []model.Line{
|
Line: []model.Line{
|
||||||
{Start: new(int64(0)), Value: "This is"},
|
{Start: gg.P(int64(0)), Value: "This is"},
|
||||||
{Start: new(int64(2500)), Value: secondLine},
|
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||||
},
|
},
|
||||||
Offset: nil,
|
Offset: nil,
|
||||||
Synced: true,
|
Synced: true,
|
||||||
|
|
@ -149,7 +151,11 @@ var _ = Describe("Extractor", func() {
|
||||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||||
unsUslt := makeLyrics("xxx", "unspecified")
|
unsUslt := makeLyrics("xxx", "unspecified")
|
||||||
|
|
||||||
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
// Why is the order inconsistent between runs? Nobody knows
|
||||||
|
Expect(lyrics).To(Or(
|
||||||
|
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
|
||||||
|
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
|
||||||
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||||
9
adapters/taglib/get_filename.go
Normal file
9
adapters/taglib/get_filename.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
func getFilename(s string) *C.char {
|
||||||
|
return C.CString(s)
|
||||||
|
}
|
||||||
96
adapters/taglib/get_filename_win.go
Normal file
96
adapters/taglib/get_filename_win.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
// From https://github.com/orofarne/gowchar
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <wchar.h>
|
||||||
|
|
||||||
|
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
|
||||||
|
|
||||||
|
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
|
||||||
|
{
|
||||||
|
arr[pos] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
wchar_t gowchar_get (wchar_t *arr, int pos)
|
||||||
|
{
|
||||||
|
return arr[pos];
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
|
||||||
|
|
||||||
|
func getFilename(s string) *C.wchar_t {
|
||||||
|
wstr, _ := StringToWcharT(s)
|
||||||
|
return wstr
|
||||||
|
}
|
||||||
|
|
||||||
|
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
|
||||||
|
switch SIZEOF_WCHAR_T {
|
||||||
|
case 2:
|
||||||
|
return stringToWchar2(s) // Windows
|
||||||
|
case 4:
|
||||||
|
return stringToWchar4(s) // Unix
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
|
||||||
|
}
|
||||||
|
panic("?!!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows
|
||||||
|
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
|
||||||
|
var slen int
|
||||||
|
s1 := s
|
||||||
|
for len(s1) > 0 {
|
||||||
|
r, size := utf8.DecodeRuneInString(s1)
|
||||||
|
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
|
||||||
|
slen += 1
|
||||||
|
} else {
|
||||||
|
slen += 2
|
||||||
|
}
|
||||||
|
s1 = s1[size:]
|
||||||
|
}
|
||||||
|
slen++ // \0
|
||||||
|
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
||||||
|
var i int
|
||||||
|
for len(s) > 0 {
|
||||||
|
r, size := utf8.DecodeRuneInString(s)
|
||||||
|
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
|
||||||
|
i++
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
s = s[size:]
|
||||||
|
}
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
||||||
|
return (*C.wchar_t)(res), C.size_t(slen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix
|
||||||
|
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
|
||||||
|
slen := utf8.RuneCountInString(s)
|
||||||
|
slen++ // \0
|
||||||
|
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
||||||
|
var i int
|
||||||
|
for len(s) > 0 {
|
||||||
|
r, size := utf8.DecodeRuneInString(s)
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
||||||
|
s = s[size:]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
||||||
|
return (*C.wchar_t)(res), C.size_t(slen)
|
||||||
|
}
|
||||||
178
adapters/taglib/taglib.go
Normal file
178
adapters/taglib/taglib.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/core/storage/local"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extractor struct {
|
||||||
|
baseDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||||
|
results := make(map[string]metadata.Info)
|
||||||
|
for _, path := range files {
|
||||||
|
props, err := e.extractMetadata(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[path] = *props
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) Version() string {
|
||||||
|
return Version()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||||
|
fullPath := filepath.Join(e.baseDir, filePath)
|
||||||
|
tags, err := Read(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse audio properties
|
||||||
|
ap := metadata.AudioProperties{}
|
||||||
|
ap.BitRate = parseProp(tags, "__bitrate")
|
||||||
|
ap.Channels = parseProp(tags, "__channels")
|
||||||
|
ap.SampleRate = parseProp(tags, "__samplerate")
|
||||||
|
ap.BitDepth = parseProp(tags, "__bitspersample")
|
||||||
|
length := parseProp(tags, "__lengthinmilliseconds")
|
||||||
|
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
|
||||||
|
|
||||||
|
// Extract basic tags
|
||||||
|
parseBasicTag(tags, "__title", "title")
|
||||||
|
parseBasicTag(tags, "__artist", "artist")
|
||||||
|
parseBasicTag(tags, "__album", "album")
|
||||||
|
parseBasicTag(tags, "__comment", "comment")
|
||||||
|
parseBasicTag(tags, "__genre", "genre")
|
||||||
|
parseBasicTag(tags, "__year", "year")
|
||||||
|
parseBasicTag(tags, "__track", "tracknumber")
|
||||||
|
|
||||||
|
// Parse track/disc totals
|
||||||
|
parseTuple := func(prop string) {
|
||||||
|
tagName := prop + "number"
|
||||||
|
tagTotal := prop + "total"
|
||||||
|
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
||||||
|
parts := strings.Split(value[0], "/")
|
||||||
|
tags[tagName] = []string{parts[0]}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
tags[tagTotal] = []string{parts[1]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseTuple("track")
|
||||||
|
parseTuple("disc")
|
||||||
|
|
||||||
|
// Adjust some ID3 tags
|
||||||
|
parseLyrics(tags)
|
||||||
|
parseTIPL(tags)
|
||||||
|
delete(tags, "tmcl") // TMCL is already parsed by TagLib
|
||||||
|
|
||||||
|
return &metadata.Info{
|
||||||
|
Tags: tags,
|
||||||
|
AudioProperties: ap,
|
||||||
|
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLyrics make sure lyrics tags have language
|
||||||
|
func parseLyrics(tags map[string][]string) {
|
||||||
|
lyrics := tags["lyrics"]
|
||||||
|
if len(lyrics) > 0 {
|
||||||
|
tags["lyrics:xxx"] = lyrics
|
||||||
|
delete(tags, "lyrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are the only roles we support, based on Picard's tag map:
|
||||||
|
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
||||||
|
var tiplMapping = map[string]string{
|
||||||
|
"arranger": "arranger",
|
||||||
|
"engineer": "engineer",
|
||||||
|
"producer": "producer",
|
||||||
|
"mix": "mixer",
|
||||||
|
"DJ-mix": "djmixer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProp parses a property from the tags map and sets it to the target integer.
|
||||||
|
// It also deletes the property from the tags map after parsing.
|
||||||
|
func parseProp(tags map[string][]string, prop string) int {
|
||||||
|
if value, ok := tags[prop]; ok && len(value) > 0 {
|
||||||
|
v, _ := strconv.Atoi(value[0])
|
||||||
|
delete(tags, prop)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
|
||||||
|
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
|
||||||
|
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
|
||||||
|
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
|
||||||
|
basicValue := tags[basicName]
|
||||||
|
if len(basicValue) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(tags, basicName)
|
||||||
|
if len(tags[tagName]) == 0 {
|
||||||
|
tags[tagName] = basicValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||||
|
//
|
||||||
|
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||||
|
//
|
||||||
|
// and breaks it down into a map of roles and names, e.g.:
|
||||||
|
//
|
||||||
|
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
||||||
|
func parseTIPL(tags map[string][]string) {
|
||||||
|
tipl := tags["tipl"]
|
||||||
|
if len(tipl) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addRole := func(currentRole string, currentValue []string) {
|
||||||
|
if currentRole != "" && len(currentValue) > 0 {
|
||||||
|
role := tiplMapping[currentRole]
|
||||||
|
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentRole string
|
||||||
|
var currentValue []string
|
||||||
|
for _, part := range strings.Split(tipl[0], " ") {
|
||||||
|
if _, ok := tiplMapping[part]; ok {
|
||||||
|
addRole(currentRole, currentValue)
|
||||||
|
currentRole = part
|
||||||
|
currentValue = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentValue = append(currentValue, part)
|
||||||
|
}
|
||||||
|
addRole(currentRole, currentValue)
|
||||||
|
delete(tags, "tipl")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ local.Extractor = (*extractor)(nil)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||||
|
// ignores fs, as taglib extractor only works with local files
|
||||||
|
return &extractor{baseDir}
|
||||||
|
})
|
||||||
|
conf.AddHook(func() {
|
||||||
|
log.Debug("TagLib version", "version", Version())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package gotaglib
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGoTagLib(t *testing.T) {
|
func TestTagLib(t *testing.T) {
|
||||||
tests.Init(t, true)
|
tests.Init(t, true)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "GoTagLib Suite")
|
RunSpecs(t, "TagLib Suite")
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package gotaglib
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -15,7 +14,7 @@ var _ = Describe("Extractor", func() {
|
||||||
var e *extractor
|
var e *extractor
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
e = &extractor{fs: os.DirFS(".")}
|
e = &extractor{}
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Parse", func() {
|
Describe("Parse", func() {
|
||||||
|
|
@ -81,11 +80,12 @@ var _ = Describe("Extractor", func() {
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||||
|
|
||||||
// TagLib 1.12 returns 18, previous versions return 39.
|
// TabLib 1.12 returns 18, previous versions return 39.
|
||||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
Expect(m.HasPicture).To(BeTrue())
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ var _ = Describe("Extractor", func() {
|
||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
|
||||||
))
|
))
|
||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
|
|
@ -128,17 +128,6 @@ var _ = Describe("Extractor", func() {
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||||
// Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
|
|
||||||
// to `originaldate`.
|
|
||||||
if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
|
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
|
||||||
}
|
|
||||||
// MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
|
|
||||||
// for original release dates.
|
|
||||||
Expect(m.Tags).To(Or(
|
|
||||||
HaveKeyWithValue("origyear", []string{"1998-07-28"}),
|
|
||||||
HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
|
|
||||||
))
|
|
||||||
|
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
|
|
@ -185,9 +174,6 @@ var _ = Describe("Extractor", func() {
|
||||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||||
|
|
||||||
// ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
|
|
||||||
Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
|
|
||||||
|
|
||||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||||
|
|
@ -214,9 +200,6 @@ var _ = Describe("Extractor", func() {
|
||||||
// Only run permission tests if we are not root
|
// Only run permission tests if we are not root
|
||||||
RegularUserContext("when run without root privileges", func() {
|
RegularUserContext("when run without root privileges", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
tests.SkipOnWindows("uses Unix file permission bits")
|
|
||||||
// Use root fs for absolute paths in temp directory
|
|
||||||
e = &extractor{fs: os.DirFS("/")}
|
|
||||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
||||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||||
|
|
@ -229,25 +212,20 @@ var _ = Describe("Extractor", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("correctly handle unreadable file due to insufficient read permission", func() {
|
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||||
// Strip leading slash for DirFS rooted at "/"
|
_, err := e.extractMetadata(accessForbiddenFile)
|
||||||
_, err := e.extractMetadata(accessForbiddenFile[1:])
|
|
||||||
Expect(err).To(MatchError(os.ErrPermission))
|
Expect(err).To(MatchError(os.ErrPermission))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("skips the file if it cannot be read", func() {
|
It("skips the file if it cannot be read", func() {
|
||||||
// Get current working directory to construct paths relative to root
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// Strip leading slash for DirFS rooted at "/"
|
|
||||||
files := []string{
|
files := []string{
|
||||||
cwd[1:] + "/tests/fixtures/test.mp3",
|
"tests/fixtures/test.mp3",
|
||||||
cwd[1:] + "/tests/fixtures/test.ogg",
|
"tests/fixtures/test.ogg",
|
||||||
accessForbiddenFile[1:],
|
accessForbiddenFile,
|
||||||
}
|
}
|
||||||
mds, err := e.Parse(files...)
|
mds, err := e.Parse(files...)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(mds).To(HaveLen(2))
|
Expect(mds).To(HaveLen(2))
|
||||||
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
|
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
299
adapters/taglib/taglib_wrapper.cpp
Normal file
299
adapters/taglib/taglib_wrapper.cpp
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define TAGLIB_STATIC
|
||||||
|
#include <apeproperties.h>
|
||||||
|
#include <apetag.h>
|
||||||
|
#include <aifffile.h>
|
||||||
|
#include <asffile.h>
|
||||||
|
#include <dsffile.h>
|
||||||
|
#include <fileref.h>
|
||||||
|
#include <flacfile.h>
|
||||||
|
#include <id3v2tag.h>
|
||||||
|
#include <unsynchronizedlyricsframe.h>
|
||||||
|
#include <synchronizedlyricsframe.h>
|
||||||
|
#include <mp4file.h>
|
||||||
|
#include <mpegfile.h>
|
||||||
|
#include <opusfile.h>
|
||||||
|
#include <tpropertymap.h>
|
||||||
|
#include <vorbisfile.h>
|
||||||
|
#include <wavfile.h>
|
||||||
|
#include <wavfile.h>
|
||||||
|
#include <wavpackfile.h>
|
||||||
|
|
||||||
|
#include "taglib_wrapper.h"
|
||||||
|
|
||||||
|
char has_cover(const TagLib::FileRef f);
|
||||||
|
|
||||||
|
static char TAGLIB_VERSION[16];
|
||||||
|
|
||||||
|
char* taglib_version() {
|
||||||
|
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
||||||
|
return (char *)TAGLIB_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||||
|
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
|
||||||
|
|
||||||
|
if (f.isNull()) {
|
||||||
|
return TAGLIB_ERR_PARSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!f.audioProperties()) {
|
||||||
|
return TAGLIB_ERR_AUDIO_PROPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audio properties to the tags
|
||||||
|
const TagLib::AudioProperties *props(f.audioProperties());
|
||||||
|
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
|
||||||
|
goPutInt(id, (char *)"__bitrate", props->bitrate());
|
||||||
|
goPutInt(id, (char *)"__channels", props->channels());
|
||||||
|
goPutInt(id, (char *)"__samplerate", props->sampleRate());
|
||||||
|
|
||||||
|
// Extract bits per sample for supported formats
|
||||||
|
int bitsPerSample = 0;
|
||||||
|
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
||||||
|
bitsPerSample = apeProperties->bitsPerSample();
|
||||||
|
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||||
|
bitsPerSample = asfProperties->bitsPerSample();
|
||||||
|
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
||||||
|
bitsPerSample = flacProperties->bitsPerSample();
|
||||||
|
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
||||||
|
bitsPerSample = mp4Properties->bitsPerSample();
|
||||||
|
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
||||||
|
bitsPerSample = wavePackProperties->bitsPerSample();
|
||||||
|
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
||||||
|
bitsPerSample = aiffProperties->bitsPerSample();
|
||||||
|
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
||||||
|
bitsPerSample = wavProperties->bitsPerSample();
|
||||||
|
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
||||||
|
bitsPerSample = dsfProperties->bitsPerSample();
|
||||||
|
|
||||||
|
if (bitsPerSample > 0) {
|
||||||
|
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all properties to the Go map
|
||||||
|
TagLib::PropertyMap tags = f.file()->properties();
|
||||||
|
|
||||||
|
// Make sure at least the basic properties are extracted
|
||||||
|
TagLib::Tag *basic = f.file()->tag();
|
||||||
|
if (!basic->isEmpty()) {
|
||||||
|
if (!basic->title().isEmpty()) {
|
||||||
|
tags.insert("__title", basic->title());
|
||||||
|
}
|
||||||
|
if (!basic->artist().isEmpty()) {
|
||||||
|
tags.insert("__artist", basic->artist());
|
||||||
|
}
|
||||||
|
if (!basic->album().isEmpty()) {
|
||||||
|
tags.insert("__album", basic->album());
|
||||||
|
}
|
||||||
|
if (!basic->comment().isEmpty()) {
|
||||||
|
tags.insert("__comment", basic->comment());
|
||||||
|
}
|
||||||
|
if (!basic->genre().isEmpty()) {
|
||||||
|
tags.insert("__genre", basic->genre());
|
||||||
|
}
|
||||||
|
if (basic->year() > 0) {
|
||||||
|
tags.insert("__year", TagLib::String::number(basic->year()));
|
||||||
|
}
|
||||||
|
if (basic->track() > 0) {
|
||||||
|
tags.insert("__track", TagLib::String::number(basic->track()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||||
|
|
||||||
|
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||||
|
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
||||||
|
if (mp3File != NULL) {
|
||||||
|
id3Tags = mp3File->ID3v2Tag();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id3Tags == NULL) {
|
||||||
|
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
|
||||||
|
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
|
||||||
|
id3Tags = wavFile->ID3v2Tag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id3Tags == NULL) {
|
||||||
|
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
|
||||||
|
if (aiffFile && aiffFile->hasID3v2Tag()) {
|
||||||
|
id3Tags = aiffFile->tag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
|
||||||
|
// with many players, so they will not be parsed
|
||||||
|
|
||||||
|
if (id3Tags != NULL) {
|
||||||
|
const auto &frames = id3Tags->frameListMap();
|
||||||
|
|
||||||
|
for (const auto &kv: frames) {
|
||||||
|
if (kv.first == "USLT") {
|
||||||
|
for (const auto &tag: kv.second) {
|
||||||
|
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
|
||||||
|
if (frame == NULL) continue;
|
||||||
|
|
||||||
|
tags.erase("LYRICS");
|
||||||
|
|
||||||
|
const auto bv = frame->language();
|
||||||
|
char language[4] = {'x', 'x', 'x', '\0'};
|
||||||
|
if (bv.size() == 3) {
|
||||||
|
strncpy(language, bv.data(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
char *val = const_cast<char*>(frame->text().toCString(true));
|
||||||
|
|
||||||
|
goPutLyrics(id, language, val);
|
||||||
|
}
|
||||||
|
} else if (kv.first == "SYLT") {
|
||||||
|
for (const auto &tag: kv.second) {
|
||||||
|
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
|
||||||
|
if (frame == NULL) continue;
|
||||||
|
|
||||||
|
const auto bv = frame->language();
|
||||||
|
char language[4] = {'x', 'x', 'x', '\0'};
|
||||||
|
if (bv.size() == 3) {
|
||||||
|
strncpy(language, bv.data(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto format = frame->timestampFormat();
|
||||||
|
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
|
||||||
|
|
||||||
|
for (const auto &line: frame->synchedText()) {
|
||||||
|
char *text = const_cast<char*>(line.text.toCString(true));
|
||||||
|
goPutLyricLine(id, language, text, line.time);
|
||||||
|
}
|
||||||
|
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
|
||||||
|
const int sampleRate = props->sampleRate();
|
||||||
|
|
||||||
|
if (sampleRate != 0) {
|
||||||
|
for (const auto &line: frame->synchedText()) {
|
||||||
|
const int timeInMs = (line.time * 1000) / sampleRate;
|
||||||
|
char *text = const_cast<char*>(line.text.toCString(true));
|
||||||
|
goPutLyricLine(id, language, text, timeInMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kv.first == "TIPL"){
|
||||||
|
if (!kv.second.isEmpty()) {
|
||||||
|
tags.insert(kv.first, kv.second.front()->toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
|
||||||
|
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
||||||
|
if (m4afile != NULL) {
|
||||||
|
const auto itemListMap = m4afile->tag()->itemMap();
|
||||||
|
for (const auto item: itemListMap) {
|
||||||
|
char *key = const_cast<char*>(item.first.toCString(true));
|
||||||
|
for (const auto value: item.second.toStringList()) {
|
||||||
|
char *val = const_cast<char*>(value.toCString(true));
|
||||||
|
goPutM4AStr(id, key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
|
||||||
|
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
|
||||||
|
if (asfFile != NULL) {
|
||||||
|
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
||||||
|
const auto itemListMap = asfTags->attributeListMap();
|
||||||
|
for (const auto item : itemListMap) {
|
||||||
|
char *key = const_cast<char*>(item.first.toCString(true));
|
||||||
|
|
||||||
|
for (auto j = item.second.begin();
|
||||||
|
j != item.second.end(); ++j) {
|
||||||
|
|
||||||
|
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||||
|
goPutStr(id, key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all collected tags to the Go map
|
||||||
|
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
|
||||||
|
++i) {
|
||||||
|
char *key = const_cast<char*>(i->first.toCString(true));
|
||||||
|
for (TagLib::StringList::ConstIterator j = i->second.begin();
|
||||||
|
j != i->second.end(); ++j) {
|
||||||
|
char *val = const_cast<char*>((*j).toCString(true));
|
||||||
|
goPutStr(id, key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover art has to be handled separately
|
||||||
|
if (has_cover(f)) {
|
||||||
|
goPutStr(id, (char *)"has_picture", (char *)"true");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
|
||||||
|
char has_cover(const TagLib::FileRef f) {
|
||||||
|
char hasCover = 0;
|
||||||
|
// ----- MP3
|
||||||
|
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
|
||||||
|
if (mp3File->ID3v2Tag()) {
|
||||||
|
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
|
||||||
|
hasCover = !frameListMap["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- FLAC
|
||||||
|
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
||||||
|
hasCover = !flacFile->pictureList().isEmpty();
|
||||||
|
}
|
||||||
|
// ----- MP4
|
||||||
|
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
|
||||||
|
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
|
||||||
|
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
|
||||||
|
hasCover = !coverArtList.isEmpty();
|
||||||
|
}
|
||||||
|
// ----- Ogg
|
||||||
|
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
||||||
|
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
|
||||||
|
}
|
||||||
|
// ----- Opus
|
||||||
|
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||||
|
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
||||||
|
}
|
||||||
|
// ----- WAV
|
||||||
|
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
|
||||||
|
if (wavFile->hasID3v2Tag()) {
|
||||||
|
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||||
|
hasCover = !frameListMap["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- AIFF
|
||||||
|
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||||
|
if (aiffFile->hasID3v2Tag()) {
|
||||||
|
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||||
|
hasCover = !frameListMap["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- WMA
|
||||||
|
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||||
|
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||||
|
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
||||||
|
}
|
||||||
|
// ----- DSF
|
||||||
|
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
|
||||||
|
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
|
||||||
|
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
// ----- WAVPAK (APE tag)
|
||||||
|
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
||||||
|
if (wvFile->hasAPETag()) {
|
||||||
|
// This is the particular string that Picard uses
|
||||||
|
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasCover;
|
||||||
|
}
|
||||||
157
adapters/taglib/taglib_wrapper.go
Normal file
157
adapters/taglib/taglib_wrapper.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo !windows pkg-config: --define-prefix taglib
|
||||||
|
#cgo windows pkg-config: taglib
|
||||||
|
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
|
||||||
|
#cgo linux darwin CXXFLAGS: -std=c++11
|
||||||
|
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "taglib_wrapper.h"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const iTunesKeyPrefix = "----:com.apple.itunes:"
|
||||||
|
|
||||||
|
func Version() string {
|
||||||
|
return C.GoString(C.taglib_version())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Read(filename string) (tags map[string][]string, err error) {
|
||||||
|
// Do not crash on failures in the C code/library
|
||||||
|
debug.SetPanicOnFault(true)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
|
||||||
|
err = fmt.Errorf("extractor: recovered from panic: %s", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fp := getFilename(filename)
|
||||||
|
defer C.free(unsafe.Pointer(fp))
|
||||||
|
id, m, release := newMap()
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
|
||||||
|
res := C.taglib_read(fp, C.ulong(id))
|
||||||
|
switch res {
|
||||||
|
case C.TAGLIB_ERR_PARSE:
|
||||||
|
// Check additional case whether the file is unreadable due to permission
|
||||||
|
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if os.IsPermission(fileErr) {
|
||||||
|
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
|
||||||
|
} else if fileErr != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("cannot parse file media file")
|
||||||
|
}
|
||||||
|
case C.TAGLIB_ERR_AUDIO_PROPS:
|
||||||
|
return nil, fmt.Errorf("can't get audio properties from file")
|
||||||
|
}
|
||||||
|
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||||
|
j, _ := json.Marshal(m)
|
||||||
|
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
|
||||||
|
} else {
|
||||||
|
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagMap map[string][]string
|
||||||
|
|
||||||
|
var allMaps sync.Map
|
||||||
|
var mapsNextID atomic.Uint32
|
||||||
|
|
||||||
|
func newMap() (uint32, tagMap, func()) {
|
||||||
|
id := mapsNextID.Add(1)
|
||||||
|
|
||||||
|
m := tagMap{}
|
||||||
|
allMaps.Store(id, m)
|
||||||
|
|
||||||
|
return id, m, func() {
|
||||||
|
allMaps.Delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doPutTag(id C.ulong, key string, val *C.char) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _ := allMaps.Load(uint32(id))
|
||||||
|
m := r.(tagMap)
|
||||||
|
k := strings.ToLower(key)
|
||||||
|
v := strings.TrimSpace(C.GoString(val))
|
||||||
|
m[k] = append(m[k], v)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutM4AStr
|
||||||
|
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
|
||||||
|
k := C.GoString(key)
|
||||||
|
|
||||||
|
// Special for M4A, do not catch keys that have no actual name
|
||||||
|
k = strings.TrimPrefix(k, iTunesKeyPrefix)
|
||||||
|
doPutTag(id, k, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutStr
|
||||||
|
func goPutStr(id C.ulong, key *C.char, val *C.char) {
|
||||||
|
doPutTag(id, C.GoString(key), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutInt
|
||||||
|
func goPutInt(id C.ulong, key *C.char, val C.int) {
|
||||||
|
valStr := strconv.Itoa(int(val))
|
||||||
|
vp := C.CString(valStr)
|
||||||
|
defer C.free(unsafe.Pointer(vp))
|
||||||
|
goPutStr(id, key, vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutLyrics
|
||||||
|
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
|
||||||
|
doPutTag(id, "lyrics:"+C.GoString(lang), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutLyricLine
|
||||||
|
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
||||||
|
language := C.GoString(lang)
|
||||||
|
line := C.GoString(text)
|
||||||
|
timeGo := int64(time)
|
||||||
|
|
||||||
|
ms := timeGo % 1000
|
||||||
|
timeGo /= 1000
|
||||||
|
sec := timeGo % 60
|
||||||
|
timeGo /= 60
|
||||||
|
minimum := timeGo % 60
|
||||||
|
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
|
||||||
|
|
||||||
|
key := "lyrics:" + language
|
||||||
|
|
||||||
|
r, _ := allMaps.Load(uint32(id))
|
||||||
|
m := r.(tagMap)
|
||||||
|
k := strings.ToLower(key)
|
||||||
|
existing, ok := m[k]
|
||||||
|
if ok {
|
||||||
|
existing[0] += formattedLine
|
||||||
|
} else {
|
||||||
|
m[k] = []string{formattedLine}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
adapters/taglib/taglib_wrapper.h
Normal file
24
adapters/taglib/taglib_wrapper.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#define TAGLIB_ERR_PARSE -1
|
||||||
|
#define TAGLIB_ERR_AUDIO_PROPS -2
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef WIN32
|
||||||
|
#define FILENAME_CHAR_T wchar_t
|
||||||
|
#else
|
||||||
|
#define FILENAME_CHAR_T char
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extern void goPutM4AStr(unsigned long id, char *key, char *val);
|
||||||
|
extern void goPutStr(unsigned long id, char *key, char *val);
|
||||||
|
extern void goPutInt(unsigned long id, char *key, int val);
|
||||||
|
extern void goPutLyrics(unsigned long id, char *lang, char *val);
|
||||||
|
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
|
||||||
|
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
||||||
|
char* taglib_version();
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
response-property-enum-value-added INFO
|
|
||||||
155
api/.vacuum.yaml
155
api/.vacuum.yaml
|
|
@ -1,155 +0,0 @@
|
||||||
extends: [[spectral:oas, recommended]]
|
|
||||||
rules:
|
|
||||||
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
|
|
||||||
# so the value check below targets `x-module` via `given` instead.
|
|
||||||
nd-operation-x-module-required:
|
|
||||||
description: Every operation belongs to exactly one capability module.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*][get,put,post,delete,patch]
|
|
||||||
then:
|
|
||||||
field: x-module
|
|
||||||
function: truthy
|
|
||||||
nd-operation-x-module:
|
|
||||||
description: Every operation's capability module is one of the known values.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*][get,put,post,delete,patch]['x-module']
|
|
||||||
then:
|
|
||||||
function: enumeration
|
|
||||||
functionOptions:
|
|
||||||
values:
|
|
||||||
- core
|
|
||||||
- streaming
|
|
||||||
- download
|
|
||||||
- artwork
|
|
||||||
- lyrics
|
|
||||||
- transcoding
|
|
||||||
- annotations
|
|
||||||
- playback
|
|
||||||
- queue
|
|
||||||
- custom-tags
|
|
||||||
- grouping
|
|
||||||
- playlists
|
|
||||||
- smart-playlists
|
|
||||||
- sync
|
|
||||||
- events
|
|
||||||
- jukebox
|
|
||||||
- sharing
|
|
||||||
- radio
|
|
||||||
- admin
|
|
||||||
nd-operation-stability-level-required:
|
|
||||||
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*][get,put,post,delete,patch]
|
|
||||||
then:
|
|
||||||
field: x-stability-level
|
|
||||||
function: truthy
|
|
||||||
nd-operation-stability-level:
|
|
||||||
description: Every operation's stability level is alpha, beta, or stable.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
|
|
||||||
then:
|
|
||||||
function: enumeration
|
|
||||||
functionOptions:
|
|
||||||
values:
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
- stable
|
|
||||||
nd-operation-required-fields:
|
|
||||||
description: Operations need a stable operationId, summary, description and tags.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*][get,put,post,delete,patch]
|
|
||||||
then:
|
|
||||||
- field: operationId
|
|
||||||
function: truthy
|
|
||||||
- field: summary
|
|
||||||
function: truthy
|
|
||||||
- field: description
|
|
||||||
function: truthy
|
|
||||||
- field: tags
|
|
||||||
function: truthy
|
|
||||||
# Our schemas live in path/response files, not root components, so this
|
|
||||||
# walks every resolved `properties` map in the document via `$..` instead.
|
|
||||||
nd-schema-property-descriptions:
|
|
||||||
description: Every schema property is documented.
|
|
||||||
severity: error
|
|
||||||
given: $..properties[*]
|
|
||||||
then:
|
|
||||||
field: description
|
|
||||||
function: truthy
|
|
||||||
# patternProperties covers the full 4xx/5xx range; needs an explicit
|
|
||||||
# `properties` entry too, or `additionalProperties: false` rejects it.
|
|
||||||
nd-error-responses-are-problems:
|
|
||||||
description: 4xx and 5xx responses use application/problem+json.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*][*].responses
|
|
||||||
then:
|
|
||||||
function: schema
|
|
||||||
functionOptions:
|
|
||||||
forceValidationOnCurrentNode: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
patternProperties:
|
|
||||||
"^[45][0-9][0-9]$":
|
|
||||||
type: object
|
|
||||||
required: [content]
|
|
||||||
properties:
|
|
||||||
content:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
application/problem+json: {}
|
|
||||||
required: [application/problem+json]
|
|
||||||
additionalProperties: false
|
|
||||||
# Same filter limitation applies here: "is this a list endpoint" is expressed
|
|
||||||
# as a JSON Schema if/then on the operation object instead of a `given` filter.
|
|
||||||
nd-list-endpoints-paginate:
|
|
||||||
description: List endpoints declare the shared offset and limit parameters.
|
|
||||||
severity: error
|
|
||||||
given: $.paths[*].get
|
|
||||||
then:
|
|
||||||
function: schema
|
|
||||||
functionOptions:
|
|
||||||
forceValidationOnCurrentNode: true
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
if:
|
|
||||||
required: [responses]
|
|
||||||
properties:
|
|
||||||
responses:
|
|
||||||
type: object
|
|
||||||
required: ['200']
|
|
||||||
properties:
|
|
||||||
'200':
|
|
||||||
type: object
|
|
||||||
required: [content]
|
|
||||||
properties:
|
|
||||||
content:
|
|
||||||
type: object
|
|
||||||
required: [application/json]
|
|
||||||
properties:
|
|
||||||
application/json:
|
|
||||||
type: object
|
|
||||||
required: [schema]
|
|
||||||
properties:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [properties]
|
|
||||||
properties:
|
|
||||||
properties:
|
|
||||||
type: object
|
|
||||||
required: [items]
|
|
||||||
then:
|
|
||||||
required: [parameters]
|
|
||||||
properties:
|
|
||||||
parameters:
|
|
||||||
type: array
|
|
||||||
allOf:
|
|
||||||
- contains:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
const: offset
|
|
||||||
- contains:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
const: limit
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
{
|
|
||||||
"openapi": "3.0.3",
|
|
||||||
"info": {
|
|
||||||
"title": "Navidrome API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
|
|
||||||
"license": {
|
|
||||||
"name": "GPL-3.0",
|
|
||||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"servers": [
|
|
||||||
{
|
|
||||||
"url": "/api/v1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
{
|
|
||||||
"name": "server",
|
|
||||||
"description": "Server discovery and the published OpenAPI document."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"/server": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getServerInfo",
|
|
||||||
"x-module": "core",
|
|
||||||
"x-stability-level": "alpha",
|
|
||||||
"tags": [
|
|
||||||
"server"
|
|
||||||
],
|
|
||||||
"summary": "Describe the server",
|
|
||||||
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Server description.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ServerInfo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"$ref": "#/components/responses/InternalError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/openapi.json": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getOpenAPISpecJSON",
|
|
||||||
"x-module": "core",
|
|
||||||
"x-stability-level": "alpha",
|
|
||||||
"tags": [
|
|
||||||
"server"
|
|
||||||
],
|
|
||||||
"summary": "Get the OpenAPI document (JSON)",
|
|
||||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "The OpenAPI document.",
|
|
||||||
"headers": {
|
|
||||||
"ETag": {
|
|
||||||
"$ref": "#/components/headers/ETag"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "OpenAPI 3.0 document."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"304": {
|
|
||||||
"$ref": "#/components/responses/NotModified"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/openapi.yaml": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getOpenAPISpecYAML",
|
|
||||||
"x-module": "core",
|
|
||||||
"x-stability-level": "alpha",
|
|
||||||
"tags": [
|
|
||||||
"server"
|
|
||||||
],
|
|
||||||
"summary": "Get the OpenAPI document (YAML)",
|
|
||||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "The OpenAPI document.",
|
|
||||||
"headers": {
|
|
||||||
"ETag": {
|
|
||||||
"$ref": "#/components/headers/ETag"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"application/yaml": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "OpenAPI 3.0 document."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"304": {
|
|
||||||
"$ref": "#/components/responses/NotModified"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"components": {
|
|
||||||
"securitySchemes": {
|
|
||||||
"bearerAuth": {
|
|
||||||
"type": "http",
|
|
||||||
"scheme": "bearer",
|
|
||||||
"bearerFormat": "JWT",
|
|
||||||
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"schemas": {
|
|
||||||
"ServerInfo": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Public server description. Everything an add-server screen needs before login.",
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"serverVersion",
|
|
||||||
"specVersion",
|
|
||||||
"setupRequired",
|
|
||||||
"loginMethods"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Human-readable server product name."
|
|
||||||
},
|
|
||||||
"serverVersion": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Version of the running server build."
|
|
||||||
},
|
|
||||||
"specVersion": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Version of the OpenAPI document this server implements."
|
|
||||||
},
|
|
||||||
"setupRequired": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "True until the first admin user has been created."
|
|
||||||
},
|
|
||||||
"loginMethods": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"password"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Problem": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
|
||||||
"required": [
|
|
||||||
"title",
|
|
||||||
"status",
|
|
||||||
"code"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Short human-readable summary, the same for all occurrences of this problem type."
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "HTTP status code of this response."
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
|
|
||||||
"enum": [
|
|
||||||
"validation",
|
|
||||||
"unauthorized",
|
|
||||||
"forbidden",
|
|
||||||
"not_found",
|
|
||||||
"method_not_allowed",
|
|
||||||
"unavailable",
|
|
||||||
"internal"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Per-field failures. Present only when `code` is `validation`.",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/ValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ValidationError": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "One field-level validation failure.",
|
|
||||||
"required": [
|
|
||||||
"field",
|
|
||||||
"message"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"field": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Why the value was rejected."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"InternalError": {
|
|
||||||
"description": "Unexpected server failure. Details are in the server log.",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Problem"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"NotModified": {
|
|
||||||
"description": "Not modified.",
|
|
||||||
"headers": {
|
|
||||||
"ETag": {
|
|
||||||
"$ref": "#/components/headers/ETag"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"headers": {
|
|
||||||
"ETag": {
|
|
||||||
"description": "Entity tag for `If-None-Match` revalidation.",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Navidrome API
|
|
||||||
version: 1.0.0
|
|
||||||
description: |
|
|
||||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
|
||||||
capability modules through `GET /server` and never sniff versions.
|
|
||||||
|
|
||||||
Enums are open: new values may be added to any enum within v1. Clients must
|
|
||||||
accept values they do not recognise instead of failing.
|
|
||||||
|
|
||||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
|
||||||
disappear without notice, `beta` and `stable` operations only change additively.
|
|
||||||
A level is only ever raised, never lowered.
|
|
||||||
|
|
||||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
|
||||||
in its `Allow` header.
|
|
||||||
license:
|
|
||||||
name: GPL-3.0
|
|
||||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
servers:
|
|
||||||
- url: /api/v1
|
|
||||||
tags:
|
|
||||||
- name: server
|
|
||||||
description: Server discovery and the published OpenAPI document.
|
|
||||||
paths:
|
|
||||||
/server:
|
|
||||||
get:
|
|
||||||
operationId: getServerInfo
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Describe the server
|
|
||||||
description: |
|
|
||||||
Returns the public server description. No authentication required.
|
|
||||||
Authenticated requests will additionally receive the implemented capability modules
|
|
||||||
once authentication is available.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Server description.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ServerInfo'
|
|
||||||
'500':
|
|
||||||
$ref: '#/components/responses/InternalError'
|
|
||||||
/openapi.json:
|
|
||||||
get:
|
|
||||||
operationId: getOpenAPISpecJSON
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Get the OpenAPI document (JSON)
|
|
||||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: The OpenAPI document.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: '#/components/headers/ETag'
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
description: OpenAPI 3.0 document.
|
|
||||||
'304':
|
|
||||||
$ref: '#/components/responses/NotModified'
|
|
||||||
/openapi.yaml:
|
|
||||||
get:
|
|
||||||
operationId: getOpenAPISpecYAML
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Get the OpenAPI document (YAML)
|
|
||||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: The OpenAPI document.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: '#/components/headers/ETag'
|
|
||||||
content:
|
|
||||||
application/yaml:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
description: OpenAPI 3.0 document.
|
|
||||||
'304':
|
|
||||||
$ref: '#/components/responses/NotModified'
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
bearerAuth:
|
|
||||||
type: http
|
|
||||||
scheme: bearer
|
|
||||||
bearerFormat: JWT
|
|
||||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
|
||||||
schemas:
|
|
||||||
ServerInfo:
|
|
||||||
type: object
|
|
||||||
description: Public server description. Everything an add-server screen needs before login.
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
- serverVersion
|
|
||||||
- specVersion
|
|
||||||
- setupRequired
|
|
||||||
- loginMethods
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: Human-readable server product name.
|
|
||||||
serverVersion:
|
|
||||||
type: string
|
|
||||||
description: Version of the running server build.
|
|
||||||
specVersion:
|
|
||||||
type: string
|
|
||||||
description: Version of the OpenAPI document this server implements.
|
|
||||||
setupRequired:
|
|
||||||
type: boolean
|
|
||||||
description: True until the first admin user has been created.
|
|
||||||
loginMethods:
|
|
||||||
type: array
|
|
||||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- password
|
|
||||||
Problem:
|
|
||||||
type: object
|
|
||||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
|
||||||
required:
|
|
||||||
- title
|
|
||||||
- status
|
|
||||||
- code
|
|
||||||
properties:
|
|
||||||
type:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
|
||||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
|
||||||
own semantics get their own URI; switch on `code` instead.
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
|
||||||
status:
|
|
||||||
type: integer
|
|
||||||
description: HTTP status code of this response.
|
|
||||||
detail:
|
|
||||||
type: string
|
|
||||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
|
||||||
enum:
|
|
||||||
- validation
|
|
||||||
- unauthorized
|
|
||||||
- forbidden
|
|
||||||
- not_found
|
|
||||||
- method_not_allowed
|
|
||||||
- unavailable
|
|
||||||
- internal
|
|
||||||
errors:
|
|
||||||
type: array
|
|
||||||
description: Per-field failures. Present only when `code` is `validation`.
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ValidationError'
|
|
||||||
ValidationError:
|
|
||||||
type: object
|
|
||||||
description: One field-level validation failure.
|
|
||||||
required:
|
|
||||||
- field
|
|
||||||
- message
|
|
||||||
properties:
|
|
||||||
field:
|
|
||||||
type: string
|
|
||||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
description: Why the value was rejected.
|
|
||||||
responses:
|
|
||||||
InternalError:
|
|
||||||
description: Unexpected server failure. Details are in the server log.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Problem'
|
|
||||||
NotModified:
|
|
||||||
description: Not modified.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: '#/components/headers/ETag'
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
description: Entity tag for `If-None-Match` revalidation.
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
35
api/embed.go
35
api/embed.go
|
|
@ -1,35 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed bundled/openapi.json
|
|
||||||
var specJSON []byte
|
|
||||||
|
|
||||||
//go:embed bundled/openapi.yaml
|
|
||||||
var specYAML []byte
|
|
||||||
|
|
||||||
func SpecJSON() []byte {
|
|
||||||
return specJSON
|
|
||||||
}
|
|
||||||
|
|
||||||
func SpecYAML() []byte {
|
|
||||||
return specYAML
|
|
||||||
}
|
|
||||||
|
|
||||||
var specVersion = sync.OnceValue(func() string {
|
|
||||||
var doc struct {
|
|
||||||
Info struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
} `json:"info"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(SpecJSON(), &doc)
|
|
||||||
return doc.Info.Version
|
|
||||||
})
|
|
||||||
|
|
||||||
func SpecVersion() string {
|
|
||||||
return specVersion()
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/getkin/kin-openapi/openapi3"
|
|
||||||
"github.com/navidrome/navidrome/api"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Bundled spec", func() {
|
|
||||||
It("embeds a valid OpenAPI 3 document", func() {
|
|
||||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
|
|
||||||
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("embeds the YAML variant", func() {
|
|
||||||
var doc map[string]any
|
|
||||||
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
|
|
||||||
Expect(doc).To(HaveKey("paths"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports the version from the bundle, matching the source root document", func() {
|
|
||||||
src, err := os.ReadFile("api/openapi/openapi.yaml")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var root struct {
|
|
||||||
Info struct{ Version string } `yaml:"info"`
|
|
||||||
}
|
|
||||||
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
|
|
||||||
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
|
|
||||||
Expect(api.SpecVersion()).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
description: Entity tag for `If-None-Match` revalidation.
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
name: limit
|
|
||||||
in: query
|
|
||||||
description: Maximum number of items to return.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
maximum: 2000
|
|
||||||
default: 100
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
name: offset
|
|
||||||
in: query
|
|
||||||
description: Zero-based index of the first item to return.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
default: 0
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: The request is malformed or fails validation.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: The caller is authenticated but not allowed to do this.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: Unexpected server failure. Details are in the server log.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: No such resource or endpoint.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
description: Not modified.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: ../headers/ETag.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: Missing, invalid, or expired credentials.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
type: object
|
|
||||||
description: Pagination metadata carried by every list response.
|
|
||||||
required: [total, offset, limit]
|
|
||||||
properties:
|
|
||||||
total:
|
|
||||||
type: integer
|
|
||||||
description: Total number of items matching the request, ignoring pagination.
|
|
||||||
offset:
|
|
||||||
type: integer
|
|
||||||
description: Zero-based index of the first returned item.
|
|
||||||
limit:
|
|
||||||
type: integer
|
|
||||||
description: Maximum number of items in this page.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
type: object
|
|
||||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
|
||||||
required: [title, status, code]
|
|
||||||
properties:
|
|
||||||
type:
|
|
||||||
type: string
|
|
||||||
description: |
|
|
||||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
|
||||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
|
||||||
own semantics get their own URI; switch on `code` instead.
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
|
||||||
status:
|
|
||||||
type: integer
|
|
||||||
description: HTTP status code of this response.
|
|
||||||
detail:
|
|
||||||
type: string
|
|
||||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
|
||||||
enum:
|
|
||||||
- validation
|
|
||||||
- unauthorized
|
|
||||||
- forbidden
|
|
||||||
- not_found
|
|
||||||
- method_not_allowed
|
|
||||||
- unavailable
|
|
||||||
- internal
|
|
||||||
errors:
|
|
||||||
type: array
|
|
||||||
description: Per-field failures. Present only when `code` is `validation`.
|
|
||||||
items:
|
|
||||||
$ref: ./ValidationError.yaml
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
type: object
|
|
||||||
description: Public server description. Everything an add-server screen needs before login.
|
|
||||||
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: Human-readable server product name.
|
|
||||||
serverVersion:
|
|
||||||
type: string
|
|
||||||
description: Version of the running server build.
|
|
||||||
specVersion:
|
|
||||||
type: string
|
|
||||||
description: Version of the OpenAPI document this server implements.
|
|
||||||
setupRequired:
|
|
||||||
type: boolean
|
|
||||||
description: True until the first admin user has been created.
|
|
||||||
loginMethods:
|
|
||||||
type: array
|
|
||||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
enum: [password]
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
type: object
|
|
||||||
description: One field-level validation failure.
|
|
||||||
required: [field, message]
|
|
||||||
properties:
|
|
||||||
field:
|
|
||||||
type: string
|
|
||||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
description: Why the value was rejected.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Navidrome API
|
|
||||||
version: 1.0.0
|
|
||||||
description: |
|
|
||||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
|
||||||
capability modules through `GET /server` and never sniff versions.
|
|
||||||
|
|
||||||
Enums are open: new values may be added to any enum within v1. Clients must
|
|
||||||
accept values they do not recognise instead of failing.
|
|
||||||
|
|
||||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
|
||||||
disappear without notice, `beta` and `stable` operations only change additively.
|
|
||||||
A level is only ever raised, never lowered.
|
|
||||||
|
|
||||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
|
||||||
in its `Allow` header.
|
|
||||||
license:
|
|
||||||
name: GPL-3.0
|
|
||||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
|
||||||
servers:
|
|
||||||
- url: /api/v1
|
|
||||||
tags:
|
|
||||||
- name: server
|
|
||||||
description: Server discovery and the published OpenAPI document.
|
|
||||||
paths:
|
|
||||||
/server:
|
|
||||||
$ref: ./paths/server.yaml
|
|
||||||
/openapi.json:
|
|
||||||
$ref: ./paths/openapi.yaml#/json
|
|
||||||
/openapi.yaml:
|
|
||||||
$ref: ./paths/openapi.yaml#/yaml
|
|
||||||
components:
|
|
||||||
securitySchemes:
|
|
||||||
bearerAuth:
|
|
||||||
type: http
|
|
||||||
scheme: bearer
|
|
||||||
bearerFormat: JWT
|
|
||||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
json:
|
|
||||||
get:
|
|
||||||
operationId: getOpenAPISpecJSON
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Get the OpenAPI document (JSON)
|
|
||||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: The OpenAPI document.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: ../components/headers/ETag.yaml
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
description: OpenAPI 3.0 document.
|
|
||||||
'304':
|
|
||||||
$ref: ../components/responses/NotModified.yaml
|
|
||||||
yaml:
|
|
||||||
get:
|
|
||||||
operationId: getOpenAPISpecYAML
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Get the OpenAPI document (YAML)
|
|
||||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: The OpenAPI document.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: ../components/headers/ETag.yaml
|
|
||||||
content:
|
|
||||||
application/yaml:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
description: OpenAPI 3.0 document.
|
|
||||||
'304':
|
|
||||||
$ref: ../components/responses/NotModified.yaml
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
get:
|
|
||||||
operationId: getServerInfo
|
|
||||||
x-module: core
|
|
||||||
x-stability-level: alpha
|
|
||||||
tags: [server]
|
|
||||||
summary: Describe the server
|
|
||||||
description: |
|
|
||||||
Returns the public server description. No authentication required.
|
|
||||||
Authenticated requests will additionally receive the implemented capability modules
|
|
||||||
once authentication is available.
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Server description.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: ../components/schemas/ServerInfo.yaml
|
|
||||||
'500':
|
|
||||||
$ref: ../components/responses/InternalError.yaml
|
|
||||||
1022
cmd/artwork.go
1022
cmd/artwork.go
File diff suppressed because it is too large
Load diff
1206
cmd/artwork_test.go
1206
cmd/artwork_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,9 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -15,7 +17,6 @@ var (
|
||||||
backupCount int
|
backupCount int
|
||||||
backupDir string
|
backupDir string
|
||||||
force bool
|
force bool
|
||||||
integrityCheck bool
|
|
||||||
restorePath string
|
restorePath string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,9 +31,8 @@ func init() {
|
||||||
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
||||||
backupRoot.AddCommand(pruneCmd)
|
backupRoot.AddCommand(pruneCmd)
|
||||||
|
|
||||||
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
|
||||||
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||||
restoreCommand.Flags().BoolVarP(&integrityCheck, "integrity-check", "i", false, "check the backup file with a full integrity check before restoring. Finds more kinds of damage, but takes longer")
|
|
||||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||||
backupRoot.AddCommand(restoreCommand)
|
backupRoot.AddCommand(restoreCommand)
|
||||||
}
|
}
|
||||||
|
|
@ -75,15 +75,27 @@ var (
|
||||||
|
|
||||||
func runBackup(ctx context.Context) {
|
func runBackup(ctx context.Context) {
|
||||||
if backupDir != "" {
|
if backupDir != "" {
|
||||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
conf.Server.Backup.Path = backupDir
|
||||||
}
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
path, err := db.Backup(ctx)
|
path, err := db.Backup(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -92,24 +104,43 @@ func runBackup(ctx context.Context) {
|
||||||
|
|
||||||
func runPrune(ctx context.Context) {
|
func runPrune(ctx context.Context) {
|
||||||
if backupDir != "" {
|
if backupDir != "" {
|
||||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
conf.Server.Backup.Path = backupDir
|
||||||
}
|
}
|
||||||
|
|
||||||
if backupCount != -1 {
|
if backupCount != -1 {
|
||||||
conf.Server.Backup.Count = backupCount
|
conf.Server.Backup.Count = backupCount
|
||||||
}
|
}
|
||||||
|
|
||||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
if conf.Server.Backup.Count == 0 && !force {
|
||||||
|
fmt.Println("Warning: pruning ALL backups")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
log.Warn("Prune cancelled")
|
log.Warn("Prune cancelled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
count, err := db.Prune(ctx)
|
count, err := db.Prune(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -118,29 +149,36 @@ func runPrune(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRestore(ctx context.Context) {
|
func runRestore(ctx context.Context) {
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
// A relative --backup-file is resolved against Backup.Path, the same folder
|
if idx == -1 {
|
||||||
// `backup create` writes to. Without this, the value was treated as relative
|
path = conf.Server.DbPath
|
||||||
// to the working directory, where the file does not exist.
|
} else {
|
||||||
if !filepath.IsAbs(restorePath) {
|
path = conf.Server.DbPath[:idx]
|
||||||
backupPath, err := conf.Server.Backup.Path.Path()
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
restorePath = filepath.Join(backupPath, restorePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
if !force {
|
||||||
|
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
log.Warn("Restore cancelled")
|
log.Warn("Restore cancelled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath, integrityCheck)
|
err := db.Restore(ctx, restorePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(doctorCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
|
||||||
Use: "doctor",
|
|
||||||
Short: "Check your Navidrome installation for problems",
|
|
||||||
Long: "Run read-only health checks and report what was found. Checks the database for " +
|
|
||||||
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
|
|
||||||
"can fix what it finds. This command never alters your data",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runDoctor(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDoctor(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
healthy := doctor(ctx, db.Db(), os.Stdout)
|
|
||||||
db.Close(ctx)
|
|
||||||
if !healthy {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
|
|
||||||
|
|
||||||
func printFindings(out io.Writer, check, noun string, items []string) {
|
|
||||||
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
|
|
||||||
for _, item := range items {
|
|
||||||
fmt.Fprintln(out, " "+item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
|
|
||||||
healthy := true
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking database integrity...")
|
|
||||||
issues, truncated, err := db.IntegrityCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
return false
|
|
||||||
case len(issues) == 0:
|
|
||||||
fmt.Fprintln(out, "Integrity check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
printFindings(out, "Integrity check", "issue(s)", issues)
|
|
||||||
switch {
|
|
||||||
case truncated:
|
|
||||||
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
case db.IsFTSCorruptionOnly(issues):
|
|
||||||
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
|
|
||||||
default:
|
|
||||||
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking foreign keys...")
|
|
||||||
violations, err := db.ForeignKeyCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
healthy = false
|
|
||||||
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
|
|
||||||
case len(violations) == 0:
|
|
||||||
fmt.Fprintln(out, "Foreign key check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
lines := make([]string, 0, len(violations))
|
|
||||||
for _, v := range violations {
|
|
||||||
lines = append(lines,
|
|
||||||
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
|
|
||||||
}
|
|
||||||
printFindings(out, "Foreign key check", "violation(s)", lines)
|
|
||||||
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
|
|
||||||
"in library data; the rest have to be removed by hand.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthy {
|
|
||||||
fmt.Fprintln(out, "Database is healthy.")
|
|
||||||
}
|
|
||||||
return healthy
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("doctor", func() {
|
|
||||||
var (
|
|
||||||
ctx context.Context
|
|
||||||
dbPath string
|
|
||||||
database *sql.DB
|
|
||||||
out *strings.Builder
|
|
||||||
reopen func()
|
|
||||||
)
|
|
||||||
|
|
||||||
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
|
|
||||||
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = context.Background()
|
|
||||||
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
|
|
||||||
reopen = func() {
|
|
||||||
var err error
|
|
||||||
database, err = sql.Open(db.Dialect, dbPath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
database.SetMaxOpenConns(1)
|
|
||||||
}
|
|
||||||
reopen()
|
|
||||||
DeferCleanup(func() { _ = database.Close() })
|
|
||||||
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
|
|
||||||
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
|
|
||||||
`create table library(id integer primary key)`,
|
|
||||||
`create table media_file(id integer primary key, library_id integer references library(id))`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
out = &strings.Builder{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports a healthy database", func() {
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeTrue())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Database is healthy."))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to 'search rebuild' when corruption is limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to a backup restore when corruption is not limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`insert into library(id)
|
|
||||||
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
|
|
||||||
select x from s`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var rootPage, pageSize int64
|
|
||||||
Expect(database.QueryRowContext(ctx,
|
|
||||||
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
|
|
||||||
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(f.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports foreign key violations", func() {
|
|
||||||
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("media_file"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
|
|
||||||
// GC never touches player, share or playqueue, so don't promise a full cleanup.
|
|
||||||
Expect(out.String()).To(ContainSubstring("removed by hand"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
|
|
||||||
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
|
|
||||||
It("does not blame the search index when the issue list is truncated", func() {
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create table t(a, b)`,
|
|
||||||
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
|
|
||||||
insert into t select x, x + 10000 from s`,
|
|
||||||
`create index media_file_fts_probe on t(a)`,
|
|
||||||
`pragma writable_schema=on`,
|
|
||||||
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
|
|
||||||
where name = 'media_file_fts_probe'`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -32,21 +28,21 @@ var inspectCmd = &cobra.Command{
|
||||||
Long: "Show file tags as seen by Navidrome",
|
Long: "Show file tags as seen by Navidrome",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runInspector(cmd.Context(), args)
|
runInspector(args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var marshalers = map[string]func(any) ([]byte, error){
|
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||||
"pretty": prettyMarshal,
|
"pretty": prettyMarshal,
|
||||||
"toml": toml.Marshal,
|
"toml": toml.Marshal,
|
||||||
"yaml": yaml.Marshal,
|
"yaml": yaml.Marshal,
|
||||||
"json": json.Marshal,
|
"json": json.Marshal,
|
||||||
"jsonindent": func(v any) ([]byte, error) {
|
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||||
return json.MarshalIndent(v, "", " ")
|
return json.MarshalIndent(v, "", " ")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyMarshal(v any) ([]byte, error) {
|
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||||
out := v.([]core.InspectOutput)
|
out := v.([]core.InspectOutput)
|
||||||
var res strings.Builder
|
var res strings.Builder
|
||||||
for i := range out {
|
for i := range out {
|
||||||
|
|
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(ctx context.Context, args []string) {
|
func runInspector(args []string) {
|
||||||
marshal := marshalers[format]
|
marshal := marshalers[format]
|
||||||
if marshal == nil {
|
if marshal == nil {
|
||||||
log.Fatal("Invalid format", "format", format)
|
log.Fatal("Invalid format", "format", format)
|
||||||
}
|
}
|
||||||
libs := loadLibraries(ctx)
|
|
||||||
matcher := model.NewLibraryMatcher(libs)
|
|
||||||
var out []core.InspectOutput
|
var out []core.InspectOutput
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
if !model.IsAudioFile(filePath) {
|
if !model.IsAudioFile(filePath) {
|
||||||
log.Warn("Not an audio file", "file", filePath)
|
log.Warn("Not an audio file", "file", filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lib, ok := libraryForFile(matcher, filePath)
|
output, err := core.Inspect(filePath, 1, "")
|
||||||
if !ok && len(libs) > 0 {
|
|
||||||
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
|
||||||
}
|
|
||||||
output, err := core.Inspect(filePath, lib, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
|
||||||
data, _ := marshal(out)
|
data, _ := marshal(out)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
|
||||||
func loadLibraries(ctx context.Context) model.Libraries {
|
|
||||||
if dbFile, ok := existingDBFile(); !ok {
|
|
||||||
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer db.Init(ctx)()
|
|
||||||
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range libs {
|
|
||||||
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
|
||||||
libs[i].Path = absPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return libs
|
|
||||||
}
|
|
||||||
|
|
||||||
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
|
||||||
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
|
||||||
if absPath, err := filepath.Abs(filePath); err == nil {
|
|
||||||
if lib, ok := matcher.FindLibrary(absPath); ok {
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.Library{ID: model.DefaultLibraryID}, false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
171
cmd/missing.go
171
cmd/missing.go
|
|
@ -1,171 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var missingListFormat string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
|
||||||
missingCmd.AddCommand(missingListCmd)
|
|
||||||
missingCmd.AddCommand(missingFixCmd)
|
|
||||||
rootCmd.AddCommand(missingCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
missingCmd = &cobra.Command{
|
|
||||||
Use: "missing",
|
|
||||||
Short: "Manage missing files",
|
|
||||||
Long: "List files marked as missing and remap them onto existing files",
|
|
||||||
}
|
|
||||||
|
|
||||||
missingListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List missing files",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runMissingList(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
missingFixCmd = &cobra.Command{
|
|
||||||
Use: "fix <missing path|id> <target path|id>",
|
|
||||||
Short: "Remap a missing file onto an existing file",
|
|
||||||
Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" +
|
|
||||||
"the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" +
|
|
||||||
"a library-relative path, or a libraryID:path pair.",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runMissingFix(cmd.Context(), args[0], args[1])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type displayMissingFile struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
LibraryID int `json:"libraryId"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Album string `json:"album"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMissingList(ctx context.Context) {
|
|
||||||
if missingListFormat != "csv" && missingListFormat != "json" {
|
|
||||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
|
|
||||||
Filters: squirrel.Eq{"missing": true},
|
|
||||||
Sort: "path",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
err = writeMissingList(os.Stdout, missingListFormat, mfs)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve missing files", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory
|
|
||||||
func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error {
|
|
||||||
if format == "json" {
|
|
||||||
bw := bufio.NewWriter(w)
|
|
||||||
_, _ = io.WriteString(bw, "[")
|
|
||||||
sep := ""
|
|
||||||
for mf, err := range mfs {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path})
|
|
||||||
_, _ = fmt.Fprintf(bw, "%s%s", sep, j)
|
|
||||||
sep = ","
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(bw, "]\n")
|
|
||||||
return bw.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
cw := csv.NewWriter(w)
|
|
||||||
_ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"})
|
|
||||||
for mf, err := range mfs {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path})
|
|
||||||
}
|
|
||||||
cw.Flush()
|
|
||||||
return cw.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMissingFix(ctx context.Context, missingRef, targetRef string) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
missing := resolveMediaFile(ctx, ds, missingRef)
|
|
||||||
target := resolveMediaFile(ctx, ds, targetRef)
|
|
||||||
|
|
||||||
if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
|
||||||
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
|
||||||
mf, err := ds.MediaFile().Get(ctx, ref)
|
|
||||||
if err == nil {
|
|
||||||
return mf
|
|
||||||
}
|
|
||||||
if !errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
|
||||||
}
|
|
||||||
if len(mfs) == 0 {
|
|
||||||
log.Fatal(ctx, "No media file found", "ref", ref)
|
|
||||||
}
|
|
||||||
mfs = preferQualified(ref, mfs)
|
|
||||||
if len(mfs) > 1 {
|
|
||||||
log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs))
|
|
||||||
}
|
|
||||||
return &mfs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path"
|
|
||||||
// reference both ways: an explicit library wins over a file literally named like one.
|
|
||||||
func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles {
|
|
||||||
id, path, ok := strings.Cut(ref, ":")
|
|
||||||
if !ok {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
libraryID, err := strconv.Atoi(id)
|
|
||||||
if err != nil {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
qualified := slice.Filter(mfs, func(mf model.MediaFile) bool {
|
|
||||||
return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path)
|
|
||||||
})
|
|
||||||
if len(qualified) == 0 {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
return qualified
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("writeMissingList", func() {
|
|
||||||
cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor {
|
|
||||||
return func(yield func(model.MediaFile, error) bool) {
|
|
||||||
for _, mf := range mfs {
|
|
||||||
if !yield(mf, nil) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
yield(model.MediaFile{}, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"}
|
|
||||||
|
|
||||||
It("writes csv with a header, quoting as needed", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed())
|
|
||||||
Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes a json array", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed())
|
|
||||||
Expect(out.String()).To(MatchJSON(`[
|
|
||||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"},
|
|
||||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}
|
|
||||||
]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes an empty json array when nothing is missing", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed())
|
|
||||||
Expect(out.String()).To(MatchJSON(`[]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the cursor's error", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("preferQualified", func() {
|
|
||||||
target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"}
|
|
||||||
decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"}
|
|
||||||
|
|
||||||
It("picks the library-qualified match over a literal path that looks like one", func() {
|
|
||||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("picks the named library when the same path exists in two", func() {
|
|
||||||
other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"}
|
|
||||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves an unqualified reference ambiguous", func() {
|
|
||||||
both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}}
|
|
||||||
Expect(preferQualified("foo.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves it alone when the prefix is not a library id", func() {
|
|
||||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
|
||||||
Expect(preferQualified("x:foo.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves it alone when no candidate matches the qualified form", func() {
|
|
||||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
|
||||||
Expect(preferQualified("9:nope.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
260
cmd/pls.go
260
cmd/pls.go
|
|
@ -6,21 +6,15 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"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/model/request"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/utils/ioutils"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,7 +23,6 @@ var (
|
||||||
outputFile string
|
outputFile string
|
||||||
userID string
|
userID string
|
||||||
outputFormat string
|
outputFormat string
|
||||||
syncFlag bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type displayPlaylist struct {
|
type displayPlaylist struct {
|
||||||
|
|
@ -51,15 +44,6 @@ func init() {
|
||||||
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
||||||
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
||||||
plsCmd.AddCommand(listCommand)
|
plsCmd.AddCommand(listCommand)
|
||||||
|
|
||||||
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
|
|
||||||
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
|
|
||||||
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
|
||||||
plsCmd.AddCommand(exportCommand)
|
|
||||||
|
|
||||||
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
|
|
||||||
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
|
|
||||||
plsCmd.AddCommand(importCommand)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -68,7 +52,7 @@ var (
|
||||||
Short: "Export playlists",
|
Short: "Export playlists",
|
||||||
Long: "Export Navidrome playlists to M3U files",
|
Long: "Export Navidrome playlists to M3U files",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runExporter(cmd.Context())
|
runExporter()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,170 +60,89 @@ var (
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List playlists",
|
Short: "List playlists",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runList(cmd.Context())
|
runList()
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCommand = &cobra.Command{
|
|
||||||
Use: "export",
|
|
||||||
Short: "Export playlists to M3U files",
|
|
||||||
Long: "Export one or more Navidrome playlists to M3U files",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runExport(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
importCommand = &cobra.Command{
|
|
||||||
Use: "import [files...]",
|
|
||||||
Short: "Import M3U playlists",
|
|
||||||
Long: "Import one or more M3U files as Navidrome playlists",
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runImport(cmd.Context(), args)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
|
func runExporter() {
|
||||||
options := model.QueryOptions{Sort: sort}
|
sqlDB := db.Db()
|
||||||
if userID != "" {
|
ds := persistence.New(sqlDB)
|
||||||
user, err := getUser(ctx, userID, ds)
|
ctx := auth.WithAdminUser(context.Background(), ds)
|
||||||
if err != nil {
|
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
|
||||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
|
||||||
}
|
|
||||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
|
||||||
}
|
|
||||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
|
||||||
}
|
|
||||||
return pls
|
|
||||||
}
|
|
||||||
|
|
||||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
|
||||||
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if playlist == nil {
|
if playlist == nil {
|
||||||
log.Fatal("Playlist not found", "name", nameOrID)
|
log.Fatal("Playlist not found", "name", playlistID)
|
||||||
}
|
}
|
||||||
return playlist
|
pls := playlist.ToM3U8()
|
||||||
}
|
if outputFile == "-" || outputFile == "" {
|
||||||
|
println(pls)
|
||||||
func runExporter(ctx context.Context) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
playlist := findPlaylist(ctx, ds, playlistID)
|
|
||||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePlaylist(m3u string, out io.Writer, file string) {
|
|
||||||
if file == "" || file == "-" {
|
|
||||||
fmt.Fprint(out, m3u)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
|
||||||
log.Fatal("Error writing to the output file", "file", file, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runExport(ctx context.Context) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
if playlistID != "" && outputFile == "" {
|
|
||||||
playlist := findPlaylist(ctx, ds, playlistID)
|
|
||||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputFile == "" {
|
err = os.WriteFile(outputFile, []byte(pls), 0600)
|
||||||
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(outputFile)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
log.Fatal("Output path must be an existing directory", "path", outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
if playlistID != "" {
|
|
||||||
pls := findPlaylist(ctx, ds, playlistID)
|
|
||||||
filename := str.SanitizeFilename(pls.Name) + ".m3u"
|
|
||||||
path := filepath.Join(outputFile, filename)
|
|
||||||
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error writing playlist", "file", path, err)
|
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allPls := fetchPlaylists(ctx, ds, "name")
|
|
||||||
|
|
||||||
nameCounts := make(map[string]int)
|
|
||||||
for _, pls := range allPls {
|
|
||||||
nameCounts[str.SanitizeFilename(pls.Name)]++
|
|
||||||
}
|
|
||||||
|
|
||||||
exported := 0
|
|
||||||
for _, pls := range allPls {
|
|
||||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized := str.SanitizeFilename(pls.Name)
|
|
||||||
filename := sanitized + ".m3u"
|
|
||||||
if nameCounts[sanitized] > 1 {
|
|
||||||
shortID := pls.ID
|
|
||||||
if len(shortID) > 6 {
|
|
||||||
shortID = shortID[:6]
|
|
||||||
}
|
|
||||||
filename = sanitized + "_" + shortID + ".m3u"
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(outputFile, filename)
|
|
||||||
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error writing playlist", "file", path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
||||||
exported++
|
|
||||||
}
|
|
||||||
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runList(ctx context.Context) {
|
func runList() {
|
||||||
if outputFormat != "csv" && outputFormat != "json" {
|
if outputFormat != "csv" && outputFormat != "json" {
|
||||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
sqlDB := db.Db()
|
||||||
allPls := fetchPlaylists(ctx, ds, "owner_name")
|
ds := persistence.New(sqlDB)
|
||||||
|
ctx := auth.WithAdminUser(context.Background(), ds)
|
||||||
|
|
||||||
|
options := model.QueryOptions{Sort: "owner_name"}
|
||||||
|
|
||||||
|
if userID != "" {
|
||||||
|
user, err := ds.User(ctx).FindByUsername(userID)
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Fatal("Error retrieving user by name", "name", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
user, err = ds.User(ctx).Get(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error retrieving user by id", "id", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlists, err := ds.Playlist(ctx).GetAll(options)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||||
|
}
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
||||||
for _, playlist := range allPls {
|
for _, playlist := range playlists {
|
||||||
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
} else {
|
} else {
|
||||||
display := make(displayPlaylists, len(allPls))
|
display := make(displayPlaylists, len(playlists))
|
||||||
for idx, playlist := range allPls {
|
for idx, playlist := range playlists {
|
||||||
display[idx].Id = playlist.ID
|
display[idx].Id = playlist.ID
|
||||||
display[idx].Name = playlist.Name
|
display[idx].Name = playlist.Name
|
||||||
display[idx].OwnerId = playlist.OwnerID
|
display[idx].OwnerId = playlist.OwnerID
|
||||||
|
|
@ -251,62 +154,3 @@ func runList(ctx context.Context) {
|
||||||
fmt.Printf("%s\n", j)
|
fmt.Printf("%s\n", j)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runImport(ctx context.Context, files []string) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
if userID != "" {
|
|
||||||
user, err := getUser(ctx, userID, ds)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
|
||||||
}
|
|
||||||
ctx = request.WithUser(ctx, *user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
absPath, err := filepath.Abs(file)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error resolving path", "file", file, err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
totalLines := countM3UTrackLines(absPath)
|
|
||||||
|
|
||||||
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error importing playlist", "file", absPath, err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
matched := len(imported.Tracks)
|
|
||||||
if totalLines > 0 {
|
|
||||||
notFound := totalLines - matched
|
|
||||||
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func countM3UTrackLines(path string) int {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
reader := ioutils.UTF8Reader(file)
|
|
||||||
for line := range slice.LinesFrom(reader) {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("writePlaylist", func() {
|
|
||||||
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
|
|
||||||
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(func() { _ = os.Remove(plsFile) })
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("writes the playlist to exactly one destination",
|
|
||||||
func(file, wantStream, wantFile string) {
|
|
||||||
var out strings.Builder
|
|
||||||
|
|
||||||
writePlaylist(m3u, &out, file)
|
|
||||||
|
|
||||||
written, _ := os.ReadFile(plsFile)
|
|
||||||
Expect(out.String()).To(Equal(wantStream))
|
|
||||||
Expect(string(written)).To(Equal(wantFile))
|
|
||||||
},
|
|
||||||
Entry("no file name writes to the stream", "", m3u, ""),
|
|
||||||
Entry("a dash writes to the stream", "-", m3u, ""),
|
|
||||||
Entry("a path writes to the file", plsFile, "", m3u),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
1151
cmd/plugin.go
1151
cmd/plugin.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,360 +1,193 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"io"
|
||||||
"encoding/json"
|
"os"
|
||||||
"errors"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var samplePlugins = model.Plugins{
|
var _ = Describe("Plugin CLI Commands", func() {
|
||||||
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
|
var tempDir string
|
||||||
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
|
var cmd *cobra.Command
|
||||||
}
|
var stdOut *os.File
|
||||||
|
var origStdout *os.File
|
||||||
|
var outReader *os.File
|
||||||
|
|
||||||
var _ = Describe("plugin command format flags", func() {
|
// Helper to create a test plugin with the given name and details
|
||||||
// Regression: list and info must not share a format variable. Binding the
|
createTestPlugin := func(name, author, version string, capabilities []string) string {
|
||||||
// same var on both commands makes the last init() registration clobber the
|
pluginDir := filepath.Join(tempDir, name)
|
||||||
// other's default, breaking `plugin list` with no -f flag.
|
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||||
It("defaults `list -f` to table", func() {
|
|
||||||
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("defaults `info -f` to text", func() {
|
// Create a properly formatted capabilities JSON array
|
||||||
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
|
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginList", func() {
|
manifest := `{
|
||||||
It("renders csv with a header and one row per plugin", func() {
|
"name": "` + name + `",
|
||||||
out, err := formatPluginList(samplePlugins, "csv")
|
"author": "` + author + `",
|
||||||
Expect(err).ToNot(HaveOccurred())
|
"version": "` + version + `",
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
"description": "Plugin for testing",
|
||||||
Expect(lines).To(HaveLen(3)) // header + 2 rows
|
"website": "https://test.navidrome.org/` + name + `",
|
||||||
Expect(lines[0]).To(ContainSubstring("id"))
|
"capabilities": [` + capabilitiesJSON + `],
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
"permissions": {}
|
||||||
Expect(out).To(ContainSubstring("beta"))
|
}`
|
||||||
})
|
|
||||||
|
|
||||||
It("renders valid json", func() {
|
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||||
out, err := formatPluginList(samplePlugins, "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var got []map[string]any
|
|
||||||
Expect(json.Unmarshal([]byte(out), &got)).To(Succeed())
|
|
||||||
Expect(got).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renders a human table by default", func() {
|
// Create a dummy WASM file
|
||||||
out, err := formatPluginList(samplePlugins, "table")
|
wasmContent := []byte("dummy wasm content for testing")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||||
Expect(out).To(ContainSubstring("Alpha"))
|
|
||||||
Expect(out).To(ContainSubstring("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors on an unknown format", func() {
|
return pluginDir
|
||||||
_, err := formatPluginList(samplePlugins, "yaml")
|
}
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("enable/disable plugin", func() {
|
// Helper to execute a command and return captured output
|
||||||
It("calls EnablePlugin on the manager", func() {
|
captureOutput := func(reader io.Reader) string {
|
||||||
mgr := &tests.MockPluginManager{}
|
stdOut.Close()
|
||||||
err := enablePlugin(context.Background(), mgr, "alpha")
|
outputBytes, err := io.ReadAll(reader)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
|
return string(outputBytes)
|
||||||
})
|
}
|
||||||
|
|
||||||
It("calls DisablePlugin on the manager", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
err := disablePlugin(context.Background(), mgr, "beta")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("applyPluginEdit", func() {
|
|
||||||
var cur *model.Plugin
|
|
||||||
BeforeEach(func() {
|
|
||||||
cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("validates then updates config when config is provided", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
cfg := `{"key":"val"}`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
|
||||||
Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("updates users with allUsers flag", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
all := true
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
|
||||||
pluginEditOptions{allUsers: &all})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("updates libraries with allLibraries and write access", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
all := true
|
|
||||||
wr := true
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
|
||||||
pluginEditOptions{allLibraries: &all, writeAccess: &wr})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("preserves existing fields when only the write-access flag changes", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
no := false
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("preserves existing users when only the all-users flag changes", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
all := true
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses a comma-separated users value into a JSON array", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
users := "alice, bob"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes a JSON-array users value through unchanged", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
users := `["alice","bob"]`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a malformed JSON-array users value", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
users := `["alice"`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses a comma-separated libraries value into a JSON array of ints", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
libs := "1, 2"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a non-integer library ID", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
libs := "1,abc"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("clears allUsers when an explicit users list is set", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
cur.AllUsers = true
|
|
||||||
users := "alice"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`))
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("clears allLibraries when an explicit libraries list is set", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
cur.AllLibraries = true
|
|
||||||
libs := "1"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`))
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does nothing and errors when no fields are set", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("aborts the config update when validation fails", func() {
|
|
||||||
mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")}
|
|
||||||
cfg := `{"key":"val"}`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("isPackagePath", func() {
|
|
||||||
It("is true for any .ndp path", func() {
|
|
||||||
Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue())
|
|
||||||
})
|
|
||||||
It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() {
|
|
||||||
Expect(isPackagePath("/nope/x.ndp")).To(BeTrue())
|
|
||||||
})
|
|
||||||
It("is false for a bare plugin id", func() {
|
|
||||||
Expect(isPackagePath("my-plugin")).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginInfo", func() {
|
|
||||||
It("renders installed plugin details as text", func() {
|
|
||||||
p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}
|
|
||||||
out, err := formatPluginInfo(p, "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
|
||||||
Expect(out).To(ContainSubstring("Alpha"))
|
|
||||||
})
|
|
||||||
It("renders json", func() {
|
|
||||||
p := &model.Plugin{ID: "alpha", Manifest: `{}`}
|
|
||||||
out, err := formatPluginInfo(p, "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatManifestInfo", func() {
|
|
||||||
It("renders text with name, version, author", func() {
|
|
||||||
m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("My Plugin"))
|
|
||||||
Expect(out).To(ContainSubstring("2.0.0"))
|
|
||||||
Expect(out).To(ContainSubstring("me"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("omits Description and Website when nil", func() {
|
|
||||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).ToNot(ContainSubstring("Description:"))
|
|
||||||
Expect(out).ToNot(ContainSubstring("Website:"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("includes Description and Website when set", func() {
|
|
||||||
desc := "a cool plugin"
|
|
||||||
site := "https://example.com"
|
|
||||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("a cool plugin"))
|
|
||||||
Expect(out).To(ContainSubstring("https://example.com"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renders valid json", func() {
|
|
||||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("\"name\""))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginInfo enriched text", func() {
|
|
||||||
var fixedTime time.Time
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
|
||||||
|
// Setup config
|
||||||
|
conf.Server.Plugins.Enabled = true
|
||||||
|
conf.Server.Plugins.Folder = tempDir
|
||||||
|
|
||||||
|
// Create a command for testing
|
||||||
|
cmd = &cobra.Command{Use: "test"}
|
||||||
|
|
||||||
|
// Setup stdout capture
|
||||||
|
origStdout = os.Stdout
|
||||||
|
var err error
|
||||||
|
outReader, stdOut, err = os.Pipe()
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
os.Stdout = stdOut
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
os.Stdout = origStdout
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
|
AfterEach(func() {
|
||||||
p := &model.Plugin{
|
os.Stdout = origStdout
|
||||||
ID: "myplugin",
|
if stdOut != nil {
|
||||||
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
|
stdOut.Close()
|
||||||
Enabled: true,
|
|
||||||
Users: "alice,bob",
|
|
||||||
Libraries: "1,2",
|
|
||||||
CreatedAt: fixedTime,
|
|
||||||
UpdatedAt: fixedTime.Add(time.Hour),
|
|
||||||
}
|
}
|
||||||
out, err := formatPluginInfo(p, "text")
|
if outReader != nil {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
outReader.Close()
|
||||||
Expect(out).To(ContainSubstring("alice,bob"))
|
|
||||||
Expect(out).To(ContainSubstring("1,2"))
|
|
||||||
Expect(out).To(ContainSubstring("subsonicapi"))
|
|
||||||
Expect(out).To(ContainSubstring("users"))
|
|
||||||
Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("omits Users and Libraries lines when empty", func() {
|
|
||||||
p := &model.Plugin{
|
|
||||||
ID: "myplugin",
|
|
||||||
Manifest: `{"name":"X","version":"1.0.0","author":"me"}`,
|
|
||||||
CreatedAt: fixedTime,
|
|
||||||
UpdatedAt: fixedTime,
|
|
||||||
}
|
}
|
||||||
out, err := formatPluginInfo(p, "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).ToNot(ContainSubstring("Users:"))
|
|
||||||
Expect(out).ToNot(ContainSubstring("Libraries:"))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not alter json output", func() {
|
Describe("Plugin list command", func() {
|
||||||
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
|
It("should list installed plugins", func() {
|
||||||
out, err := formatPluginInfo(p, "json")
|
// Create test plugins
|
||||||
Expect(err).ToNot(HaveOccurred())
|
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||||
Expect(out).To(ContainSubstring(`"x"`))
|
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
|
||||||
})
|
|
||||||
})
|
// Execute command
|
||||||
|
pluginList(cmd, []string{})
|
||||||
var _ = Describe("formatManifestInfo enriched text", func() {
|
|
||||||
It("includes Permissions when declared", func() {
|
// Verify output
|
||||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
output := captureOutput(outReader)
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
Expect(output).To(ContainSubstring("plugin1"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(output).To(ContainSubstring("Test Author"))
|
||||||
Expect(out).To(ContainSubstring("http"))
|
Expect(output).To(ContainSubstring("1.0.0"))
|
||||||
Expect(out).To(ContainSubstring("Permissions:"))
|
Expect(output).To(ContainSubstring("MetadataAgent"))
|
||||||
})
|
|
||||||
|
Expect(output).To(ContainSubstring("plugin2"))
|
||||||
It("omits Permissions line when no permissions declared", func() {
|
Expect(output).To(ContainSubstring("Another Author"))
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
|
Expect(output).To(ContainSubstring("2.1.0"))
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
Expect(output).To(ContainSubstring("Scrobbler"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
})
|
||||||
Expect(out).ToNot(ContainSubstring("Permissions:"))
|
})
|
||||||
})
|
|
||||||
|
Describe("Plugin info command", func() {
|
||||||
It("does not alter json output", func() {
|
It("should display information about an installed plugin", func() {
|
||||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
// Create test plugin with multiple capabilities
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
createTestPlugin("test-plugin", "Test Author", "1.0.0",
|
||||||
out, err := formatManifestInfo(m, "abc123", "json")
|
[]string{"MetadataAgent", "Scrobbler"})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring(`"name"`))
|
// Execute command
|
||||||
})
|
pluginInfo(cmd, []string{"test-plugin"})
|
||||||
})
|
|
||||||
|
// Verify output
|
||||||
var _ = Describe("rescanPlugins", func() {
|
output := captureOutput(outReader)
|
||||||
It("calls RescanPlugins on the manager", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
Expect(output).To(ContainSubstring("Name: test-plugin"))
|
||||||
err := rescanPlugins(context.Background(), mgr)
|
Expect(output).To(ContainSubstring("Author: Test Author"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(output).To(ContainSubstring("Version: 1.0.0"))
|
||||||
Expect(mgr.RescanPluginsCalls).To(Equal(1))
|
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
|
||||||
|
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Plugin remove command", func() {
|
||||||
|
It("should remove a regular plugin directory", func() {
|
||||||
|
// Create test plugin
|
||||||
|
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
|
||||||
|
[]string{"MetadataAgent"})
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
pluginRemove(cmd, []string{"regular-plugin"})
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
output := captureOutput(outReader)
|
||||||
|
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
|
||||||
|
|
||||||
|
// Verify directory is actually removed
|
||||||
|
_, err := os.Stat(pluginDir)
|
||||||
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should remove only the symlink for a development plugin", func() {
|
||||||
|
// Create a real source directory
|
||||||
|
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
|
||||||
|
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
|
||||||
|
|
||||||
|
manifest := `{
|
||||||
|
"name": "dev-plugin",
|
||||||
|
"author": "Dev Author",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Development plugin for testing",
|
||||||
|
"website": "https://test.navidrome.org/dev-plugin",
|
||||||
|
"capabilities": ["Scrobbler"],
|
||||||
|
"permissions": {}
|
||||||
|
}`
|
||||||
|
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a dummy WASM file
|
||||||
|
wasmContent := []byte("dummy wasm content for testing")
|
||||||
|
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a symlink in the plugins directory
|
||||||
|
symlinkPath := filepath.Join(tempDir, "dev-plugin")
|
||||||
|
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
pluginRemove(cmd, []string{"dev-plugin"})
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
output := captureOutput(outReader)
|
||||||
|
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
|
||||||
|
Expect(output).To(ContainSubstring("target directory preserved"))
|
||||||
|
|
||||||
|
// Verify the symlink is removed but source directory exists
|
||||||
|
_, err := os.Lstat(symlinkPath)
|
||||||
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
||||||
|
_, err = os.Stat(sourceDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
187
cmd/root.go
187
cmd/root.go
|
|
@ -2,7 +2,6 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -10,9 +9,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"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"
|
||||||
|
|
@ -23,12 +22,6 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
// Import adapters to register them
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -44,9 +37,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
preRun()
|
preRun()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runNavidrome(cmd.Context()); err != nil {
|
runNavidrome(cmd.Context())
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
PostRun: func(cmd *cobra.Command, args []string) {
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
postRun()
|
postRun()
|
||||||
|
|
@ -78,24 +69,20 @@ func postRun() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
// it will cancel the context and exit gracefully.
|
||||||
func runNavidrome(parentCtx context.Context) error {
|
func runNavidrome(ctx context.Context) {
|
||||||
defer db.Init(parentCtx)()
|
defer db.Init(ctx)()
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(parentCtx)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaller(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
g.Go(startPlaybackServer(ctx))
|
g.Go(startPlaybackServer(ctx))
|
||||||
g.Go(startJellyfinDiscovery(ctx))
|
|
||||||
g.Go(schedulePeriodicBackup(ctx))
|
g.Go(schedulePeriodicBackup(ctx))
|
||||||
g.Go(startInsightsCollector(ctx))
|
g.Go(startInsightsCollector(ctx))
|
||||||
g.Go(scheduleDBAnalyzer(ctx))
|
g.Go(scheduleDBOptimizer(ctx))
|
||||||
g.Go(startPluginManager(ctx))
|
g.Go(startPluginManager(ctx))
|
||||||
artworkWorker := CreateArtworkWorker()
|
|
||||||
g.Go(startArtworkWorker(ctx, artworkWorker))
|
|
||||||
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
|
||||||
g.Go(runInitialScan(ctx))
|
g.Go(runInitialScan(ctx))
|
||||||
if conf.Server.Scanner.Enabled {
|
if conf.Server.Scanner.Enabled {
|
||||||
g.Go(startScanWatcher(ctx))
|
g.Go(startScanWatcher(ctx))
|
||||||
|
|
@ -104,11 +91,9 @@ func runNavidrome(parentCtx context.Context) error {
|
||||||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors caused by a normal shutdown are not failures
|
if err := g.Wait(); err != nil {
|
||||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
|
@ -134,12 +119,6 @@ func startServer(ctx context.Context) func() error {
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||||
}
|
}
|
||||||
if conf.Server.Jellyfin.Enabled {
|
|
||||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
|
||||||
}
|
|
||||||
if conf.Server.DevAPIv1 {
|
|
||||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
|
||||||
}
|
|
||||||
if conf.Server.Prometheus.Enabled {
|
if conf.Server.Prometheus.Enabled {
|
||||||
p := CreatePrometheus()
|
p := CreatePrometheus()
|
||||||
// blocking call because takes <100ms but useful if fails
|
// blocking call because takes <100ms but useful if fails
|
||||||
|
|
@ -147,7 +126,7 @@ func startServer(ctx context.Context) func() error {
|
||||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
||||||
}
|
}
|
||||||
if conf.Server.DevEnableProfiler {
|
if conf.Server.DevEnableProfiler {
|
||||||
a.MountRouter("Profiling", "/debug", profilerHandler())
|
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||||
|
|
@ -156,14 +135,6 @@ func startServer(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// profilerHandler returns the pprof handler. net/http/pprof resolves the profile
|
|
||||||
// name from the raw request path, so the BasePath has to come off first.
|
|
||||||
func profilerHandler() http.Handler {
|
|
||||||
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
|
|
||||||
basePath := strings.TrimRight(conf.Server.BasePath, "/")
|
|
||||||
return http.StripPrefix(basePath, middleware.Profiler())
|
|
||||||
}
|
|
||||||
|
|
||||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
|
|
@ -190,50 +161,45 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||||
// the one used by their last finished scan
|
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
|
||||||
libs, err := ds.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
var names []string
|
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||||
for _, lib := range libs {
|
if err != nil {
|
||||||
if lib.PIDChanged() {
|
return false, err
|
||||||
names = append(names, lib.Name)
|
|
||||||
}
|
}
|
||||||
}
|
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
||||||
return names, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
pidHasChanged, err := pidHashChanged(ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||||
case len(pidChangedLibs) > 0:
|
case pidHasChanged:
|
||||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
log.Warn(ctx, "PID config changed, performing full scan")
|
||||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
fullScanRequired = "1"
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
@ -303,24 +269,16 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
if !conf.Server.EnableScheduledDBAnalyze {
|
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
|
|
||||||
schedulerInstance := scheduler.GetInstance()
|
schedulerInstance := scheduler.GetInstance()
|
||||||
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
|
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||||
release, ok := scanner.LockForMaintenance()
|
if scanner.IsScanning() {
|
||||||
if !ok {
|
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||||
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer release()
|
db.Optimize(ctx)
|
||||||
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error analyzing DB", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -355,18 +313,6 @@ func startInsightsCollector(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
|
|
||||||
func startJellyfinDiscovery(ctx context.Context) func() error {
|
|
||||||
return func() error {
|
|
||||||
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
|
|
||||||
log.Debug("Jellyfin auto-discovery is DISABLED")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
CreateJellyfinDiscovery().Serve(ctx)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||||
// It is responsible for the Jukebox functionality
|
// It is responsible for the Jukebox functionality
|
||||||
func startPlaybackServer(ctx context.Context) func() error {
|
func startPlaybackServer(ctx context.Context) func() error {
|
||||||
|
|
@ -381,77 +327,26 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startArtworkWorker starts the background artwork acquisition worker. It always
|
|
||||||
// runs; the queue is simply empty until something enqueues work into it.
|
|
||||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
|
||||||
return func() error {
|
|
||||||
log.Info(ctx, "Starting artwork worker")
|
|
||||||
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
|
|
||||||
worker.PauseWhile(scanner.IsScanning)
|
|
||||||
return worker.Run(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
|
|
||||||
// until it ends; both write to the DB, and competing for the lock can make either fail.
|
|
||||||
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
|
|
||||||
release, ok := scanner.LockForMaintenance()
|
|
||||||
if !ok {
|
|
||||||
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
if err := run(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error running "+job, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
|
||||||
// reports an artwork config change without acting on it.
|
|
||||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
|
||||||
return func() error {
|
|
||||||
schedulerInstance := scheduler.GetInstance()
|
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
|
||||||
}); err != nil {
|
|
||||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
|
||||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
|
||||||
}); err != nil {
|
|
||||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
|
||||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
|
||||||
|
|
||||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startPluginManager starts the plugin manager, if configured.
|
// startPluginManager starts the plugin manager, if configured.
|
||||||
func startPluginManager(ctx context.Context) func() error {
|
func startPluginManager(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
manager := GetPluginManager(ctx)
|
|
||||||
if !conf.Server.Plugins.Enabled {
|
if !conf.Server.Plugins.Enabled {
|
||||||
log.Debug("Plugin system is DISABLED")
|
log.Debug("Plugins are DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Info(ctx, "Starting plugin manager")
|
log.Info(ctx, "Starting plugin manager")
|
||||||
return manager.Start(ctx)
|
// Get the manager instance and scan for plugins
|
||||||
|
manager := GetPluginManager(ctx)
|
||||||
|
manager.ScanPlugins()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement some struct tags to map flags to viper
|
// TODO: Implement some struct tags to map flags to viper
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(func() {
|
cobra.OnInitialize(func() {
|
||||||
conf.InitConfig(cfgFile, true)
|
conf.InitConfig(cfgFile)
|
||||||
})
|
})
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
|
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
|
||||||
|
|
@ -479,13 +374,12 @@ func init() {
|
||||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||||
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
|
||||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||||
rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
|
|
||||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||||
|
|
||||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint")
|
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||||
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
||||||
|
|
||||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||||
|
|
@ -503,7 +397,6 @@ func init() {
|
||||||
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
|
||||||
|
|
||||||
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
|
||||||
_ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
|
|
||||||
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
|
||||||
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path"
|
|
||||||
"runtime/pprof"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = pprof.NewProfile("nd-profiler-test")
|
|
||||||
|
|
||||||
var _ = Describe("profilerHandler", func() {
|
|
||||||
// Mirrors how server.MountRouter mounts the handler.
|
|
||||||
mount := func() http.Handler {
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("serves a named profile",
|
|
||||||
func(basePath string) {
|
|
||||||
conf.Server.BasePath = basePath
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
|
|
||||||
mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
||||||
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0"))
|
|
||||||
},
|
|
||||||
Entry("without a BasePath", ""),
|
|
||||||
Entry("with a BasePath", "/music"),
|
|
||||||
Entry("with a root BasePath", "/"),
|
|
||||||
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"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
98
cmd/scan.go
98
cmd/scan.go
|
|
@ -1,19 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/utils/pl"
|
"github.com/navidrome/navidrome/utils/pl"
|
||||||
|
|
@ -23,15 +17,11 @@ import (
|
||||||
var (
|
var (
|
||||||
fullScan bool
|
fullScan bool
|
||||||
subprocess bool
|
subprocess bool
|
||||||
targets []string
|
|
||||||
targetFile string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||||
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
||||||
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
|
||||||
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
|
|
||||||
rootCmd.AddCommand(scanCmd)
|
rootCmd.AddCommand(scanCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,20 +34,15 @@ var scanCmd = &cobra.Command{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
|
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||||
var changesDetected bool
|
|
||||||
var scanErrors []error
|
|
||||||
for status := range pl.ReadOrDone(ctx, progress) {
|
for status := range pl.ReadOrDone(ctx, progress) {
|
||||||
if status.Warning != "" {
|
if status.Warning != "" {
|
||||||
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
||||||
}
|
}
|
||||||
if status.Error != "" {
|
if status.Error != "" {
|
||||||
log.Error(ctx, "Scan error", "error", status.Error)
|
log.Error(ctx, "Scan error", "error", status.Error)
|
||||||
scanErrors = append(scanErrors, errors.New(status.Error))
|
|
||||||
}
|
|
||||||
if status.ChangesDetected {
|
|
||||||
changesDetected = true
|
|
||||||
}
|
}
|
||||||
|
// Discard the progress status, we only care about errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if fullScan {
|
if fullScan {
|
||||||
|
|
@ -65,7 +50,6 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
|
||||||
} else {
|
} else {
|
||||||
log.Info("Finished rescan")
|
log.Info("Finished rescan")
|
||||||
}
|
}
|
||||||
return changesDetected, errors.Join(scanErrors...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||||
|
|
@ -82,37 +66,9 @@ func runScanner(ctx context.Context) {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
defer db.Db().Close()
|
defer db.Db().Close()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
pls := core.NewPlaylists(ds)
|
||||||
|
|
||||||
// Parse targets from command line or file
|
progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
|
||||||
var scanTargets []model.ScanTarget
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if targetFile != "" {
|
|
||||||
scanTargets, err = readTargetsFromFile(targetFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to read targets from file", err)
|
|
||||||
}
|
|
||||||
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
|
|
||||||
} else if len(targets) > 0 {
|
|
||||||
scanTargets, err = model.ParseTargets(targets)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to parse targets", err)
|
|
||||||
}
|
|
||||||
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveFullScan := fullScan
|
|
||||||
if !subprocess {
|
|
||||||
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
|
|
||||||
if effectiveFullScan {
|
|
||||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to scan", err)
|
log.Fatal(ctx, "Failed to scan", err)
|
||||||
}
|
}
|
||||||
|
|
@ -121,48 +77,6 @@ func runScanner(ctx context.Context) {
|
||||||
if subprocess {
|
if subprocess {
|
||||||
trackScanAsSubprocess(ctx, progress)
|
trackScanAsSubprocess(ctx, progress)
|
||||||
} else {
|
} else {
|
||||||
changesDetected, scanErr := trackScanInteractively(ctx, progress)
|
trackScanInteractively(ctx, progress)
|
||||||
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
|
|
||||||
if changesDetected {
|
|
||||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if effectiveFullScan && scanErr == nil {
|
|
||||||
if err := db.Optimize(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error analyzing DB", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTargetsFromFile reads scan targets from a file, one per line.
|
|
||||||
// Each line should be in the format "libraryID:folderPath".
|
|
||||||
// Empty lines and lines starting with # are ignored.
|
|
||||||
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open target file: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var targetStrings []string
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
// Skip empty lines and comments
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
targetStrings = append(targetStrings, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read target file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.ParseTargets(targetStrings)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
104
cmd/scan_test.go
104
cmd/scan_test.go
|
|
@ -1,104 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/scanner"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("trackScanInteractively", func() {
|
|
||||||
It("reports changes and scan errors", func() {
|
|
||||||
progress := make(chan *scanner.ProgressInfo, 2)
|
|
||||||
progress <- &scanner.ProgressInfo{ChangesDetected: true}
|
|
||||||
progress <- &scanner.ProgressInfo{Error: "scan failed"}
|
|
||||||
close(progress)
|
|
||||||
|
|
||||||
changesDetected, err := trackScanInteractively(context.Background(), progress)
|
|
||||||
Expect(changesDetected).To(BeTrue())
|
|
||||||
Expect(err).To(MatchError("scan failed"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("readTargetsFromFile", func() {
|
|
||||||
var tempDir string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
var err error
|
|
||||||
tempDir, err = os.MkdirTemp("", "navidrome-test-")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
AfterEach(func() {
|
|
||||||
os.RemoveAll(tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reads valid targets from file", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(3))
|
|
||||||
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
|
|
||||||
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
|
|
||||||
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips empty lines", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("trims whitespace", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(2))
|
|
||||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
|
||||||
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error for non-existent file", func() {
|
|
||||||
_, err := readTargetsFromFile("/nonexistent/file.txt")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error for invalid target format", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "invalid-format\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
_, err = readTargetsFromFile(filePath)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles mixed valid and empty lines", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var searchRebuildForce bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(searchRoot)
|
|
||||||
|
|
||||||
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
|
|
||||||
searchRoot.AddCommand(searchRebuildCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
searchRoot = &cobra.Command{
|
|
||||||
Use: "search",
|
|
||||||
Short: "Search index maintenance",
|
|
||||||
}
|
|
||||||
|
|
||||||
searchRebuildCmd = &cobra.Command{
|
|
||||||
Use: "rebuild",
|
|
||||||
Short: "Rebuild the full-text search index",
|
|
||||||
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
|
|
||||||
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
|
|
||||||
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
|
|
||||||
"library. This must be done offline",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runSearchRebuild(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func runSearchRebuild(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
|
|
||||||
log.Warn("Rebuild cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Rebuilding the search index...")
|
|
||||||
err := db.RebuildFTS(ctx, db.Db())
|
|
||||||
db.Close(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error rebuilding the search index", err)
|
|
||||||
}
|
|
||||||
fmt.Println("Search index rebuilt successfully.")
|
|
||||||
}
|
|
||||||
43
cmd/svc.go
43
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
err := runNavidrome(p.ctx)
|
runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
|
||||||
// service manager see the failure and restart the service
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +74,15 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "SIGKILL"
|
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||||
options["UserService"] = false
|
options["UserService"] = false
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder
|
||||||
options["SystemdScript"] = systemdScript
|
options["SystemdScript"] = systemdScript
|
||||||
if conf.Server.LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
options["LogOutput"] = false
|
options["LogOutput"] = false
|
||||||
} else {
|
} else {
|
||||||
options["LogOutput"] = true
|
options["LogOutput"] = true
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder
|
||||||
}
|
}
|
||||||
svcConfig := &service.Config{
|
svcConfig := &service.Config{
|
||||||
UserName: installUser,
|
UserName: installUser,
|
||||||
|
|
@ -136,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
|
||||||
println("Installing service with:")
|
println("Installing service with:")
|
||||||
println(" working directory: " + executablePath())
|
println(" working directory: " + executablePath())
|
||||||
println(" music folder: " + conf.Server.MusicFolder)
|
println(" music folder: " + conf.Server.MusicFolder)
|
||||||
println(" data folder: " + conf.Server.DataFolder.String())
|
println(" data folder: " + conf.Server.DataFolder)
|
||||||
if conf.Server.LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
println(" log file: " + conf.Server.LogFile)
|
println(" log file: " + conf.Server.LogFile)
|
||||||
} else {
|
} else {
|
||||||
println(" logs folder: " + conf.Server.DataFolder.String())
|
println(" logs folder: " + conf.Server.DataFolder)
|
||||||
}
|
}
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||||
|
|
@ -237,22 +232,22 @@ func buildExecuteCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemdScript = `[Unit]
|
const systemdScript = `[Unit]
|
||||||
Description={{Description}}
|
Description={{.Description}}
|
||||||
ConditionFileIsExecutable={{Path | cmdEscape}}
|
ConditionFileIsExecutable={{.Path|cmdEscape}}
|
||||||
{{range Dependencies}}{{.}}
|
{{range $i, $dep := .Dependencies}}
|
||||||
{{end}}
|
{{$dep}} {{end}}
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
StartLimitInterval=5
|
StartLimitInterval=5
|
||||||
StartLimitBurst=10
|
StartLimitBurst=10
|
||||||
ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
|
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||||
{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}}
|
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||||
{{if UserName}}User={{UserName}}{{end}}
|
{{if .UserName}}User={{.UserName}}{{end}}
|
||||||
{{if Restart}}Restart={{Restart}}{{end}}
|
{{if .Restart}}Restart={{.Restart}}{{end}}
|
||||||
{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}}
|
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
|
||||||
TimeoutStopSec=20
|
TimeoutStopSec=20
|
||||||
RestartSec=120
|
RestartSec=120
|
||||||
EnvironmentFile=-/etc/sysconfig/{{Name}}
|
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||||
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
|
||||||
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|
@ -264,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
RestrictNamespaces=yes
|
RestrictNamespaces=yes
|
||||||
RestrictRealtime=yes
|
RestrictRealtime=yes
|
||||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||||
{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}}
|
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("systemdScript template", func() {
|
|
||||||
systemdKeys := map[string]bool{
|
|
||||||
"Description": true, "Path": true, "Name": true, "Dependencies": true,
|
|
||||||
"Arguments": true, "ChRoot": true, "WorkingDirectory": true,
|
|
||||||
"UserName": true, "ReloadSignal": true, "PIDFile": true,
|
|
||||||
"LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true,
|
|
||||||
"Restart": true, "SuccessExitStatus": true, "EnvVars": true,
|
|
||||||
}
|
|
||||||
systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true}
|
|
||||||
|
|
||||||
actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`)
|
|
||||||
|
|
||||||
parseAction := func(action string) (key string, funcs []string) {
|
|
||||||
action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-"))
|
|
||||||
kw, rest, _ := strings.Cut(action, " ")
|
|
||||||
switch kw {
|
|
||||||
case "end", "else":
|
|
||||||
return "", nil
|
|
||||||
case "if", "range":
|
|
||||||
return strings.TrimSpace(rest), nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(action, "|")
|
|
||||||
for _, p := range parts[1:] {
|
|
||||||
funcs = append(funcs, strings.TrimSpace(p))
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[0]), funcs
|
|
||||||
}
|
|
||||||
|
|
||||||
It("only references keys and functions the service library provides", func() {
|
|
||||||
matches := actionRe.FindAllStringSubmatch(systemdScript, -1)
|
|
||||||
Expect(matches).ToNot(BeEmpty())
|
|
||||||
|
|
||||||
for _, m := range matches {
|
|
||||||
key, funcs := parseAction(m[1])
|
|
||||||
if key != "" && key != "." {
|
|
||||||
Expect(systemdKeys).To(HaveKey(key),
|
|
||||||
"template action %q uses a key unknown to kardianos/service", m[0])
|
|
||||||
}
|
|
||||||
for _, fn := range funcs {
|
|
||||||
Expect(systemdFuncs).To(HaveKey(fn),
|
|
||||||
"template action %q uses an unknown pipeline function", m[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
475
cmd/user.go
475
cmd/user.go
|
|
@ -1,475 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
email string
|
|
||||||
libraryIds []int
|
|
||||||
name string
|
|
||||||
|
|
||||||
removeEmail bool
|
|
||||||
removeName bool
|
|
||||||
setAdmin bool
|
|
||||||
setPassword bool
|
|
||||||
setRegularUser bool
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(userRoot)
|
|
||||||
|
|
||||||
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
|
|
||||||
|
|
||||||
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
|
|
||||||
userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
|
|
||||||
|
|
||||||
userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
|
|
||||||
userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
|
|
||||||
|
|
||||||
_ = userCreateCommand.MarkFlagRequired("username")
|
|
||||||
|
|
||||||
userRoot.AddCommand(userCreateCommand)
|
|
||||||
|
|
||||||
userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
|
|
||||||
_ = userDeleteCommand.MarkFlagRequired("user")
|
|
||||||
userRoot.AddCommand(userDeleteCommand)
|
|
||||||
|
|
||||||
userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
|
|
||||||
|
|
||||||
userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
|
|
||||||
userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
|
|
||||||
userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
|
|
||||||
|
|
||||||
userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
|
|
||||||
userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
|
|
||||||
userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
|
|
||||||
|
|
||||||
userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
|
|
||||||
userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
|
|
||||||
userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
|
|
||||||
|
|
||||||
userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
|
|
||||||
|
|
||||||
userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
|
|
||||||
|
|
||||||
_ = userEditCommand.MarkFlagRequired("user")
|
|
||||||
userRoot.AddCommand(userEditCommand)
|
|
||||||
|
|
||||||
userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
|
||||||
userRoot.AddCommand(userListCommand)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
userRoot = &cobra.Command{
|
|
||||||
Use: "user",
|
|
||||||
Short: "Administer users",
|
|
||||||
Long: "Create, delete, list, or update users",
|
|
||||||
}
|
|
||||||
|
|
||||||
userCreateCommand = &cobra.Command{
|
|
||||||
Use: "create",
|
|
||||||
Aliases: []string{"c"},
|
|
||||||
Short: "Create a new user",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runCreateUser(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
userDeleteCommand = &cobra.Command{
|
|
||||||
Use: "delete",
|
|
||||||
Aliases: []string{"d"},
|
|
||||||
Short: "Deletes an existing user",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runDeleteUser(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
userEditCommand = &cobra.Command{
|
|
||||||
Use: "edit",
|
|
||||||
Aliases: []string{"e"},
|
|
||||||
Short: "Edit a user",
|
|
||||||
Long: "Edit the password, admin status, and/or library access",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runUserEdit(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
userListCommand = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List users",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runUserList(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func promptPassword() string {
|
|
||||||
for {
|
|
||||||
fmt.Print("Enter new password (press enter with no password to cancel): ")
|
|
||||||
// This cast is necessary for some platforms
|
|
||||||
password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error getting password", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
|
|
||||||
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error getting password confirmation", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear the line.
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
pass := string(password)
|
|
||||||
confirm := string(confirmation)
|
|
||||||
|
|
||||||
if pass == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if pass == confirm {
|
|
||||||
return pass
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Password and password confirmation do not match")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func libraryError(libraries model.Libraries) error {
|
|
||||||
ids := make([]int, len(libraries))
|
|
||||||
for idx, library := range libraries {
|
|
||||||
ids[idx] = library.ID
|
|
||||||
}
|
|
||||||
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCreateUser(ctx context.Context) {
|
|
||||||
password := promptPassword()
|
|
||||||
if password == "" {
|
|
||||||
log.Fatal("Empty password provided, user creation cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
user := model.User{
|
|
||||||
UserName: userID,
|
|
||||||
Email: email,
|
|
||||||
Name: name,
|
|
||||||
IsAdmin: setAdmin,
|
|
||||||
NewPassword: password,
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Name == "" {
|
|
||||||
user.Name = userID
|
|
||||||
}
|
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
|
||||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
|
||||||
if existingUser != nil {
|
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
||||||
return fmt.Errorf("failed to check existing username: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(user.Libraries) != len(libraryIds) {
|
|
||||||
return libraryError(user.Libraries)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.User().Put(ctx, &user)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedIds := make([]int, len(user.Libraries))
|
|
||||||
for idx, lib := range user.Libraries {
|
|
||||||
updatedIds[idx] = lib.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDeleteUser(ctx context.Context) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
var user *model.User
|
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
|
||||||
count, err := tx.User().CountAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if count == 1 {
|
|
||||||
return errors.New("refusing to delete the last user")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err = getUser(ctx, userID, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.User().Delete(ctx, user.ID)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to delete user", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info(ctx, "Deleted user", "username", user.UserName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runUserEdit(ctx context.Context) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
var user *model.User
|
|
||||||
changes := []string{}
|
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
|
||||||
var newLibraries model.Libraries
|
|
||||||
|
|
||||||
user, err = getUser(ctx, userID, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
|
||||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(libraries) != len(libraryIds) {
|
|
||||||
return libraryError(libraries)
|
|
||||||
}
|
|
||||||
|
|
||||||
newLibraries = libraries
|
|
||||||
changes = append(changes, "updated library ids")
|
|
||||||
}
|
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
|
||||||
libraries, err := tx.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.IsAdmin = true
|
|
||||||
user.Libraries = libraries
|
|
||||||
changes = append(changes, "set admin")
|
|
||||||
|
|
||||||
newLibraries = libraries
|
|
||||||
}
|
|
||||||
|
|
||||||
if setRegularUser && user.IsAdmin {
|
|
||||||
user.IsAdmin = false
|
|
||||||
changes = append(changes, "set regular user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if setPassword {
|
|
||||||
password := promptPassword()
|
|
||||||
|
|
||||||
if password != "" {
|
|
||||||
user.NewPassword = password
|
|
||||||
changes = append(changes, "updated password")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if email != "" && email != user.Email {
|
|
||||||
user.Email = email
|
|
||||||
changes = append(changes, "updated email")
|
|
||||||
} else if removeEmail && user.Email != "" {
|
|
||||||
user.Email = ""
|
|
||||||
changes = append(changes, "removed email")
|
|
||||||
}
|
|
||||||
|
|
||||||
if name != "" && name != user.Name {
|
|
||||||
user.Name = name
|
|
||||||
changes = append(changes, "updated name")
|
|
||||||
} else if removeName && user.Name != "" {
|
|
||||||
user.Name = ""
|
|
||||||
changes = append(changes, "removed name")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(changes) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := tx.User().Put(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(newLibraries) > 0 {
|
|
||||||
updatedIds := make([]int, len(newLibraries))
|
|
||||||
for idx, lib := range newLibraries {
|
|
||||||
updatedIds[idx] = lib.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to update user", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(changes) == 0 {
|
|
||||||
log.Info(ctx, "No changes for user", "user", user.UserName)
|
|
||||||
} else {
|
|
||||||
log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type displayLibrary struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type displayUser struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Admin bool `json:"admin"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
LastAccess *time.Time `json:"lastAccess"`
|
|
||||||
LastLogin *time.Time `json:"lastLogin"`
|
|
||||||
Libraries []displayLibrary `json:"libraries"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runUserList(ctx context.Context) {
|
|
||||||
if outputFormat != "csv" && outputFormat != "json" {
|
|
||||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
userList, err := ds.User().ReadAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
|
||||||
w := csv.NewWriter(os.Stdout)
|
|
||||||
_ = w.Write([]string{
|
|
||||||
"user id",
|
|
||||||
"username",
|
|
||||||
"user's name",
|
|
||||||
"user email",
|
|
||||||
"admin",
|
|
||||||
"created at",
|
|
||||||
"updated at",
|
|
||||||
"last access",
|
|
||||||
"last login",
|
|
||||||
"libraries",
|
|
||||||
})
|
|
||||||
for _, user := range userList {
|
|
||||||
paths := make([]string, len(user.Libraries))
|
|
||||||
|
|
||||||
for idx, library := range user.Libraries {
|
|
||||||
paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastAccess, lastLogin string
|
|
||||||
|
|
||||||
if user.LastAccessAt != nil {
|
|
||||||
lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
|
|
||||||
} else {
|
|
||||||
lastAccess = "never"
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.LastLoginAt != nil {
|
|
||||||
lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
|
|
||||||
} else {
|
|
||||||
lastLogin = "never"
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = w.Write([]string{
|
|
||||||
user.ID,
|
|
||||||
user.UserName,
|
|
||||||
user.Name,
|
|
||||||
user.Email,
|
|
||||||
strconv.FormatBool(user.IsAdmin),
|
|
||||||
user.CreatedAt.Format(time.RFC3339Nano),
|
|
||||||
user.UpdatedAt.Format(time.RFC3339Nano),
|
|
||||||
lastAccess,
|
|
||||||
lastLogin,
|
|
||||||
fmt.Sprintf("'%s'", strings.Join(paths, "|")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
} else {
|
|
||||||
users := make([]displayUser, len(userList))
|
|
||||||
for idx, user := range userList {
|
|
||||||
paths := make([]displayLibrary, len(user.Libraries))
|
|
||||||
|
|
||||||
for idx, library := range user.Libraries {
|
|
||||||
paths[idx].ID = library.ID
|
|
||||||
paths[idx].Path = library.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
users[idx].Id = user.ID
|
|
||||||
users[idx].Username = user.UserName
|
|
||||||
users[idx].Name = user.Name
|
|
||||||
users[idx].Email = user.Email
|
|
||||||
users[idx].Admin = user.IsAdmin
|
|
||||||
users[idx].CreatedAt = user.CreatedAt
|
|
||||||
users[idx].UpdatedAt = user.UpdatedAt
|
|
||||||
users[idx].LastAccess = user.LastAccessAt
|
|
||||||
users[idx].LastLogin = user.LastLoginAt
|
|
||||||
users[idx].Libraries = paths
|
|
||||||
}
|
|
||||||
|
|
||||||
j, _ := json.Marshal(users)
|
|
||||||
fmt.Printf("%s\n", j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
74
cmd/utils.go
74
cmd/utils.go
|
|
@ -1,74 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
)
|
|
||||||
|
|
||||||
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
|
|
||||||
func existingDBFile() (string, bool) {
|
|
||||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return path, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireExistingDB aborts the command when the database file does not exist.
|
|
||||||
func requireExistingDB() {
|
|
||||||
if path, ok := existingDBFile(); !ok {
|
|
||||||
log.Fatal("No existing database", "path", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmYES(warning string) bool {
|
|
||||||
fmt.Println(warning)
|
|
||||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
|
||||||
var input string
|
|
||||||
_, err := fmt.Scanln(&input)
|
|
||||||
return input == "YES" && err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTabWriter keeps every CLI table on the same column settings.
|
|
||||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
|
||||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
ds := persistence.New(sqlDB)
|
|
||||||
ctx = auth.WithAdminUser(ctx, ds)
|
|
||||||
u, _ := request.UserFrom(ctx)
|
|
||||||
if !u.IsAdmin {
|
|
||||||
log.Fatal(ctx, "There must be at least one admin user to run this command.")
|
|
||||||
}
|
|
||||||
return ds, ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
|
||||||
user, err := ds.User().FindByUsername(ctx, id)
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
||||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
user, err = ds.User().Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
212
cmd/wire_gen.go
212
cmd/wire_gen.go
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
// +build !wireinject
|
// +build !wireinject
|
||||||
|
|
||||||
|
|
@ -9,41 +9,30 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
|
||||||
"github.com/navidrome/navidrome/core/matcher"
|
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/core/quickconnect"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/sonic"
|
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
"github.com/navidrome/navidrome/server/public"
|
"github.com/navidrome/navidrome/server/public"
|
||||||
"github.com/navidrome/navidrome/server/subsonic"
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
)
|
)
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injectors from wire_injectors.go:
|
// Injectors from wire_injectors.go:
|
||||||
|
|
@ -58,7 +47,9 @@ func CreateServer() *server.Server {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
insights := metrics.GetInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
|
insights := metrics.GetInstance(dataStore, manager)
|
||||||
serverServer := server.New(dataStore, broker, insights)
|
serverServer := server.New(dataStore, broker, insights)
|
||||||
return serverServer
|
return serverServer
|
||||||
}
|
}
|
||||||
|
|
@ -67,22 +58,22 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
uploader := artwork.NewUploader(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
insights := metrics.GetInstance(dataStore)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
insights := metrics.GetInstance(dataStore, manager)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
fileCache := artwork.GetImageCache()
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
fFmpeg := ffmpeg.New()
|
||||||
user := core.NewUser(dataStore, manager)
|
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
quickConnect := quickconnect.GetInstance()
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
broker := events.GetBroker()
|
||||||
|
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
|
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||||
|
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
|
||||||
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
|
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,63 +81,24 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
|
||||||
players := core.NewPlayers(dataStore)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
uploader := artwork.NewUploader(dataStore)
|
transcodingCache := core.GetTranscodingCache()
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
share := core.NewShare(dataStore)
|
||||||
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
|
players := core.NewPlayers(dataStore)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||||
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)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
fileCache := artwork.GetImageCache()
|
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
players := core.NewPlayers(dataStore)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
|
||||||
uploader := artwork.NewUploader(dataStore)
|
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
|
||||||
quickConnect := quickconnect.GetInstance()
|
|
||||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
router := apiv1.New(dataStore)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,15 +106,17 @@ func CreatePublicRouter() *public.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
transcodingCache := core.GetTranscodingCache()
|
||||||
|
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,17 +134,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
discovery := jellyfin.NewDiscovery(dataStore)
|
|
||||||
return discovery
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
insights := metrics.GetInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
|
insights := metrics.GetInstance(dataStore, manager)
|
||||||
return insights
|
return insights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,26 +150,38 @@ func CreatePrometheus() metrics.Metrics {
|
||||||
return metricsMetrics
|
return metricsMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateScanner(ctx context.Context) model.Scanner {
|
func CreateScanner(ctx context.Context) scanner.Scanner {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
fileCache := artwork.GetImageCache()
|
||||||
uploader := artwork.NewUploader(dataStore)
|
fFmpeg := ffmpeg.New()
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
return modelScanner
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
|
return scannerScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
fileCache := artwork.GetImageCache()
|
||||||
uploader := artwork.NewUploader(dataStore)
|
fFmpeg := ffmpeg.New()
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
|
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,46 +192,19 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||||
return playbackServer
|
return playbackServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkWorker() *artwork.Worker {
|
func getPluginManager() plugins.Manager {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
fileCache := artwork.GetImageCache()
|
|
||||||
worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache)
|
|
||||||
return worker
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
|
|
||||||
return tracingResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPluginManager() *plugins.Manager {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,21 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/sonic"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
"github.com/navidrome/navidrome/server/public"
|
"github.com/navidrome/navidrome/server/public"
|
||||||
"github.com/navidrome/navidrome/server/subsonic"
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
|
|
@ -36,31 +31,22 @@ var allProviders = wire.NewSet(
|
||||||
artwork.Set,
|
artwork.Set,
|
||||||
server.New,
|
server.New,
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
jellyfin.New,
|
|
||||||
jellyfin.NewDiscovery,
|
|
||||||
apiv1.New,
|
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
lastfm.NewRouter,
|
lastfm.NewRouter,
|
||||||
listenbrainz.NewRouter,
|
listenbrainz.NewRouter,
|
||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
scanner.GetInstance,
|
scanner.New,
|
||||||
scanner.GetWatcher,
|
scanner.GetWatcher,
|
||||||
|
plugins.GetManager,
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
plugins.GetManager,
|
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
||||||
sonic.New,
|
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
||||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
|
||||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(core.Scanner), new(scanner.Scanner)),
|
||||||
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
|
||||||
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
|
|
||||||
wire.Bind(new(sonic.Engine), new(*sonic.Sonic)),
|
|
||||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
|
||||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
|
||||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
|
||||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||||
wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateDataStore() model.DataStore {
|
func CreateDataStore() model.DataStore {
|
||||||
|
|
@ -87,18 +73,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
@ -117,12 +91,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
@ -135,7 +103,7 @@ func CreatePrometheus() metrics.Metrics {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateScanner(ctx context.Context) model.Scanner {
|
func CreateScanner(ctx context.Context) scanner.Scanner {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
))
|
))
|
||||||
|
|
@ -153,26 +121,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkWorker() *artwork.Worker {
|
func getPluginManager() plugins.Manager {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
artwork.NewTracingResolver,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPluginManager() *plugins.Manager {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|
|
||||||
4
conf/buildtags/buildtags.go
Normal file
4
conf/buildtags/buildtags.go
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
package buildtags
|
||||||
|
|
||||||
|
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||||
|
// required build tags are disabled.
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// Package buildtags provides compile-time enforcement of required build tags.
|
|
||||||
//
|
|
||||||
// Each file in this package is guarded by a build constraint and exports a variable
|
|
||||||
// that main.go references. If a required tag is missing during compilation, the build
|
|
||||||
// fails with an "undefined" error, directing the developer to use `make build`.
|
|
||||||
package buildtags
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
package buildtags
|
package buildtags
|
||||||
|
|
||||||
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
|
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
||||||
|
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
|
||||||
|
// file requires it.
|
||||||
|
|
||||||
|
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
|
||||||
|
|
||||||
var NETGO = true
|
var NETGO = true
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
//go:build sqlite_fts5
|
|
||||||
|
|
||||||
package buildtags
|
|
||||||
|
|
||||||
// FTS5 is required for full-text search. Without this tag, the SQLite driver
|
|
||||||
// won't include FTS5 support, causing runtime failures on migrations and search queries.
|
|
||||||
|
|
||||||
var SQLITE_FTS5 = true
|
|
||||||
|
|
@ -2,7 +2,9 @@ package configtest
|
||||||
|
|
||||||
import "github.com/navidrome/navidrome/conf"
|
import "github.com/navidrome/navidrome/conf"
|
||||||
|
|
||||||
// TODO Remove this redirection and call SnapshotConfig directly from tests
|
|
||||||
func SetupConfig() func() {
|
func SetupConfig() func() {
|
||||||
return conf.SnapshotConfig()
|
oldValues := *conf.Server
|
||||||
|
return func() {
|
||||||
|
conf.Server = &oldValues
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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