mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 03:18:13 -05:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af4b2bb4c9 | ||
|
|
4773adba00 | ||
|
|
7bbf4cbaea | ||
|
|
cff19445ba | ||
|
|
0d920c7832 | ||
|
|
957a73e052 | ||
|
|
abc418eaa2 | ||
|
|
1128322011 | ||
|
|
2e479defd5 | ||
|
|
8311a7f215 | ||
|
|
6ec8f78076 | ||
|
|
3e879d2a8c | ||
|
|
6d3d005fca | ||
|
|
c12510d6e2 | ||
|
|
0bd73bd3f4 | ||
|
|
8c120ee3c9 | ||
|
|
9590b3c25d | ||
|
|
4887c33053 | ||
|
|
da21acba92 | ||
|
|
9154e44eb4 | ||
|
|
2e01063429 | ||
|
|
597e5abed6 | ||
|
|
92994efe48 | ||
|
|
9628b1389d | ||
|
|
347424009d | ||
|
|
ecac74c2bd | ||
|
|
ddfde7bfc8 | ||
|
|
96c50d369a | ||
|
|
310c816cdd | ||
|
|
bd402fb2a8 | ||
|
|
8bb141b730 | ||
|
|
f25b91b4d8 | ||
|
|
f959701d9d | ||
|
|
61dd8d55ca | ||
|
|
bbb9461000 | ||
|
|
95016f687e | ||
|
|
c3cc7dee01 | ||
|
|
7847f19c9d | ||
|
|
7a0df4429e | ||
|
|
6a8d2dc87d | ||
|
|
de816e8e5d | ||
|
|
b22d0366d5 | ||
|
|
fea2de8f90 | ||
|
|
d6dd0aaae7 | ||
|
|
458017b112 | ||
|
|
e6bfa2bb0b | ||
|
|
1c7fb74a1d | ||
|
|
83ae2ba3e6 | ||
|
|
2ccc5bc941 | ||
|
|
406554f1c4 | ||
|
|
e89cdf6199 | ||
|
|
cf804a52ef | ||
|
|
628fd69d3d | ||
|
|
1d00d1e986 | ||
|
|
607c4067b8 | ||
|
|
e3079d81ea | ||
|
|
3bedd89c17 | ||
|
|
57829bfa4c | ||
|
|
b998c05ca0 | ||
|
|
05d381c26f | ||
|
|
59a9c056b4 | ||
|
|
0de81b8352 | ||
|
|
91785ecf36 | ||
|
|
65eeb5ec1a | ||
|
|
17e0cd5504 | ||
|
|
3a6d2dcd49 | ||
|
|
183b462fed | ||
|
|
16fc4eb792 | ||
|
|
6fee744d99 | ||
|
|
74d5c7bc82 | ||
|
|
880fc9e195 | ||
|
|
1430aa108d | ||
|
|
673880d661 | ||
|
|
7ea111322b | ||
|
|
377e7ebd52 | ||
|
|
23c483da10 | ||
|
|
c380139606 | ||
|
|
63fbccf5a9 | ||
|
|
1f6ec1d9f5 | ||
|
|
cad8156353 | ||
|
|
f7d4fcdcc1 | ||
|
|
002cb4ed71 | ||
|
|
e13eaebbde | ||
|
|
539c0faedb | ||
|
|
4ccb6ccb09 | ||
|
|
ec0eb2866b | ||
|
|
b520d8827a | ||
|
|
a7d3e6e1f1 | ||
|
|
a22eef39f7 | ||
|
|
50d9838652 | ||
|
|
016454c217 | ||
|
|
41a5db72e7 | ||
|
|
6e6ec58429 | ||
|
|
c88e1baa7c | ||
|
|
e16e3d2e7b | ||
|
|
339a6239fd | ||
|
|
47f15ccbc3 | ||
|
|
9667f3cd48 | ||
|
|
5773fa0349 | ||
|
|
527c378c41 | ||
|
|
caa0788853 | ||
|
|
40b14e6d81 | ||
|
|
becd50eb68 | ||
|
|
15b5aa9143 | ||
|
|
7987d982cf | ||
|
|
1dd074bbb4 | ||
|
|
7eac9d2bbe | ||
|
|
362d8c50fe | ||
|
|
01c604ba7b | ||
|
|
2c129a2890 | ||
|
|
5fc4076aec | ||
|
|
d303ad2676 | ||
|
|
c4a68c8a0a | ||
|
|
ad9ce98cc2 | ||
|
|
a134b1b608 | ||
|
|
6dce4b2478 | ||
|
|
10108c63c9 | ||
|
|
aac6e2cb07 | ||
|
|
0ffdb2eee0 | ||
|
|
8b93962fad | ||
|
|
b129cae0d8 | ||
|
|
2400e4f60d | ||
|
|
3cd934abd7 | ||
|
|
727632b616 | ||
|
|
9e268678f2 | ||
|
|
bb29ad3b12 | ||
|
|
b68ed2e4f9 | ||
|
|
0c3ac906b8 | ||
|
|
b0e58cb885 | ||
|
|
806713719f | ||
|
|
a3b8682d44 | ||
|
|
0bbb54934b | ||
|
|
759ff844e2 | ||
|
|
b8c5e49dd3 | ||
|
|
05c6cdea1a | ||
|
|
fc8462dc8a | ||
|
|
9d459fbd0a | ||
|
|
9b2dd1bb06 | ||
|
|
bfaf4a3388 | ||
|
|
a7f15facf9 | ||
|
|
ee8f6447eb | ||
|
|
dad4949a6d | ||
|
|
3ce3185118 | ||
|
|
a50d9c8b67 | ||
|
|
f8dfb3ad86 | ||
|
|
255f8e4a76 | ||
|
|
eba70ab826 | ||
|
|
ee6b10db72 | ||
|
|
797cc87141 | ||
|
|
bfbe980637 | ||
|
|
d9d0a97674 | ||
|
|
c031167bb1 | ||
|
|
4a25e6d3d8 | ||
|
|
ad2ad514b3 | ||
|
|
588ee94f7c | ||
|
|
3c5032a3e8 | ||
|
|
bcab3cc0f9 | ||
|
|
9b81aa4403 | ||
|
|
f904784e67 | ||
|
|
0ce750d469 | ||
|
|
cf04db7a98 | ||
|
|
f4b50c493c | ||
|
|
4a7e86e989 | ||
|
|
a1a5b2fc30 | ||
|
|
f00e6117ff | ||
|
|
d8e794317f | ||
|
|
128b626ec9 | ||
|
|
d683297fa7 | ||
|
|
aaf58bbd32 | ||
|
|
58c46827cd | ||
|
|
712d8f9fcc | ||
|
|
b6fcfa9fc8 | ||
|
|
762a1ba998 |
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Use this template for submitting a bug report.
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
<!-- Please check that another issue for the same bug has not been already made by searching the [issues](https://github.com/navidrome/navidrome/issues) -->
|
||||
|
||||
### Description
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
### Expected Behaviour
|
||||
|
||||
What you would have expected to happen instead.
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1. Open the '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
### Platform information
|
||||
|
||||
- Navidrome version: <!-- e.g. v0.40.0 -->
|
||||
- Browser and version: <!-- e.g. Firefox v87.0b9 -->
|
||||
- Operating System: <!-- e.g. Ubuntu 20.04 and whether using a binary, docker or built from source -->
|
||||
|
||||
### Additional information
|
||||
|
||||
Any other information that may be relevant or give context to the problem.
|
||||
|
||||
- Screenshots (if applicable)?
|
||||
- Logs? <!-- Turn the log level up to trace -->
|
||||
- Client used? <!-- e.g. DSub v5.5.2R2 -->
|
||||
103
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
103
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
name: Bug Report
|
||||
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
#assignees:
|
||||
# - deluan
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Thanks for taking the time to fill out this bug report!
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: "I confirm that:"
|
||||
options:
|
||||
- label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered
|
||||
required: true
|
||||
- label: I'm using the latest version (your issue may have been fixed already)
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
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:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: env
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: Ubuntu 20.04
|
||||
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
||||
- **Client**: DSub 5.5.1
|
||||
value: |
|
||||
- OS:
|
||||
- Browser:
|
||||
- Client:
|
||||
render: markdown
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: How Navidrome is installed?
|
||||
multiple: false
|
||||
options:
|
||||
- Docker
|
||||
- Binary (from downloads page)
|
||||
- Package
|
||||
- Built from sources
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
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.
|
||||
render: toml
|
||||
- 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
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||
options:
|
||||
- label: I agree to follow Navidrome's Code of Conduct
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ideas for new features
|
||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
||||
about: This is the place to share and discuss new ideas and potentially new features.
|
||||
- name: Support requests
|
||||
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
||||
about: This is the place to ask questions.
|
||||
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
24
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Use this template to request for a feature.
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
<!-- Please check that another issue for the same feature request has not been already made by searching the [issues](https://github.com/navidrome/navidrome/issues) -->
|
||||
|
||||
### Is your feature request related to a problem? Please describe.
|
||||
|
||||
A clear and concise description of what the problem is. For e.g. I'm always frustrated when '...'
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
A clear and concise description of what you would like to happen.
|
||||
|
||||
### Describe alternative solutions that would also satisfy this problem
|
||||
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
### Additional context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
22
.github/workflows/docker-tags.sh
vendored
22
.github/workflows/docker-tags.sh
vendored
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
GIT_TAG="${GITHUB_REF##refs/tags/}"
|
||||
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
|
||||
|
||||
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
|
||||
|
||||
if [[ $PR_NUM != "null" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:pr-${PR_NUM}"
|
||||
fi
|
||||
|
||||
if [[ $GITHUB_REF != "$GIT_TAG" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
|
||||
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
|
||||
elif [[ $GIT_BRANCH = feature/* ]]; then
|
||||
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
|
||||
fi
|
||||
|
||||
echo ${DOCKER_IMAGE_TAG}
|
||||
2
.github/workflows/download-link-on-pr.yml
vendored
2
.github/workflows/download-link-on-pr.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Add download link to PR
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Pipeline']
|
||||
workflows: ['Pipeline: Test, Lint, Build']
|
||||
types: [completed]
|
||||
jobs:
|
||||
pr_comment:
|
||||
|
||||
65
.github/workflows/pipeline.yml
vendored
65
.github/workflows/pipeline.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Pipeline
|
||||
name: 'Pipeline: Test, Lint, Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Set up Go 1.19
|
||||
- name: Set up Go 1.20
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
go-version: 1.20.x
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "To fix this check, run: goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"
|
||||
echo 'To fix this check, run "goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go_version: [1.18.x,1.19.x]
|
||||
go_version: [1.20.x,1.19.x]
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
|
||||
run: go test -race -cover ./... -v
|
||||
run: go test -shuffle=on -race -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Build JS bundle
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
path: ui/build
|
||||
|
||||
- name: Config /github/workspace folder as trusted
|
||||
uses: docker://deluan/ci-goreleaser:1.19.5-1
|
||||
uses: docker://deluan/ci-goreleaser:1.20.3-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.19.5-1
|
||||
uses: docker://deluan/ci-goreleaser:1.20.3-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker://deluan/ci-goreleaser:1.19.5-1
|
||||
uses: docker://deluan/ci-goreleaser:1.20.3-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Build Docker images
|
||||
name: Build and publish Docker images
|
||||
needs: [binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -183,11 +183,42 @@ jobs:
|
||||
name: binaries
|
||||
path: dist
|
||||
|
||||
- name: Build the Docker image and push
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
env:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
DOCKER_PLATFORM: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
run: |
|
||||
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
|
||||
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
labels: |
|
||||
maintainer=deluan
|
||||
images: |
|
||||
name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}}
|
||||
name=ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=develop,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and Push
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: .github/workflows/pipeline.dockerfile
|
||||
platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
55
.github/workflows/stale.yml
vendored
Normal file
55
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v4.0.0
|
||||
with:
|
||||
issue-inactive-days: 120
|
||||
pr-inactive-days: 120
|
||||
log-output: true
|
||||
add-issue-labels: 'frozen-due-to-age'
|
||||
add-pr-labels: 'frozen-due-to-age'
|
||||
issue-comment: >
|
||||
This issue has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
pr-comment: >
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
operations-per-run: 999
|
||||
days-before-issue-stale: 180
|
||||
days-before-pr-stale: 180
|
||||
days-before-issue-close: 30
|
||||
days-before-pr-close: 30
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
|
||||
|
||||
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
|
||||
|
||||
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
|
||||
|
||||
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||
stale-pr-message: This PR has been automatically marked as stale because it has not had
|
||||
recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
|
||||
|
||||
Please check https://github.com/navidrome/navidrome/blob/master/CONTRIBUTING.md#pull-requests and verify that this code contribution fits with the description. If yes, tell it in a comment.
|
||||
|
||||
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'keep,security'
|
||||
stale-pr-label: 'stale'
|
||||
exempt-pr-labels: 'keep,security'
|
||||
6
.github/workflows/update-translations.yml
vendored
6
.github/workflows/update-translations.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Update Translations
|
||||
name: POEditor import
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
@@ -14,6 +14,10 @@ jobs:
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
run: |
|
||||
./update-translations.sh
|
||||
- name: Show changes, if any
|
||||
run: |
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,12 +14,14 @@ navidrome.toml
|
||||
master.zip
|
||||
testDB
|
||||
navidrome.db
|
||||
cache/*
|
||||
*.swp
|
||||
embedded_gen.go
|
||||
dist
|
||||
music
|
||||
docker-compose.yml
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
.gitinfo
|
||||
docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
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.
|
||||
|
||||
- [Asking Support Questions](#asking-support-questions)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Issues](#issues)
|
||||
- [Questions](#questions)
|
||||
- [Pull Requests](#pull-requests)
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
## Code of Conduct
|
||||
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||
|
||||
@@ -19,9 +22,6 @@ to the GitHub repository.
|
||||
**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)**
|
||||
|
||||
## Questions
|
||||
We would like to have discussions and general queries related to Navidrome on our [Discord channel](https://discord.gg/2qMuMyHfSV).
|
||||
|
||||
## 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)
|
||||
|
||||
28
Makefile
28
Makefile
@@ -9,7 +9,7 @@ GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION=1.19.5-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
CI_RELEASER_VERSION=1.20.3-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@@ -21,15 +21,15 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env ##@Development Start the backend in development mode
|
||||
@go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo watch -notify ./...
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: ##@Development Run Go tests
|
||||
go test -race ./...
|
||||
go test -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: test ##@Development Run Go and JS tests
|
||||
@@ -37,24 +37,30 @@ testall: test ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v --timeout 5m
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@(cd ./ui && npm run check-formatting && npm run lint)
|
||||
@(cd ./ui && npm run check-formatting) || (echo "\n\nPlease run 'npm run prettier' to fix formatting issues." && exit 1)
|
||||
@(cd ./ui && npm run lint)
|
||||
.PHONY: lintall
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go run github.com/google/wire/cmd/wire ./...
|
||||
go run github.com/google/wire/cmd/wire@latest ./...
|
||||
.PHONY: wire
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo ./server/subsonic/...
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo@latest ./server/subsonic/...
|
||||
.PHONY: snapshots
|
||||
|
||||
migration: ##@Development Create an empty migration file
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/cmd/goose -dir db/migration create ${name}
|
||||
migration-sql: ##@Development Create an empty SQL migration file
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name} sql
|
||||
.PHONY: migration
|
||||
|
||||
migration-go: ##@Development Create an empty Go migration file
|
||||
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
|
||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name}
|
||||
.PHONY: migration
|
||||
|
||||
setup-dev: setup
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
JS: sh -c "cd ./ui && npm start"
|
||||
GO: go run github.com/cespare/reflex -d none -c reflex.conf
|
||||
GO: go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
|
||||
14
README.md
14
README.md
@@ -1,6 +1,6 @@
|
||||
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
|
||||
|
||||
# Navidrome Music Server
|
||||
# Navidrome Music Server [](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
|
||||
|
||||
[](https://github.com/navidrome/navidrome/releases)
|
||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||
@@ -30,11 +30,15 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
|
||||
|
||||
## Installation
|
||||
|
||||
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
|
||||
|
||||
If you plan to host Navidrome in the cloud, a great option is to get a virtual server at [BuyVM](https://my.frantech.ca/aff.php?aff=4605).
|
||||
They have plans that start at $3.50/month! If you decide to sign up, please consider using our [affliliate link](https://my.frantech.ca/aff.php?aff=4605),
|
||||
to help support the project <3
|
||||
## Cloud Hosting
|
||||
|
||||
[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 share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||
|
||||
[](https://www.pikapods.com/pods?run=navidrome)
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ func init() {
|
||||
}
|
||||
|
||||
var plsCmd = &cobra.Command{
|
||||
Use: "pls",
|
||||
Short: "Export playlists",
|
||||
Long: "Export Navidrome playlists to M3U files",
|
||||
Use: "playlists",
|
||||
Aliases: []string{"pls", "playlist"},
|
||||
Short: "Export playlists",
|
||||
Long: "Export Navidrome playlists to M3U files",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runExporter()
|
||||
},
|
||||
|
||||
28
cmd/root.go
28
cmd/root.go
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
@@ -38,7 +39,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome()
|
||||
runNavidrome(context.Background())
|
||||
},
|
||||
Version: consts.Version,
|
||||
}
|
||||
@@ -59,8 +60,8 @@ func preRun() {
|
||||
conf.Load()
|
||||
}
|
||||
|
||||
func runNavidrome() {
|
||||
db.EnsureLatestVersion()
|
||||
func runNavidrome(ctx context.Context) {
|
||||
db.Init()
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Error("Error closing DB", err)
|
||||
@@ -68,7 +69,7 @@ func runNavidrome() {
|
||||
log.Info("Navidrome stopped, bye.")
|
||||
}()
|
||||
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaler(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
@@ -96,10 +97,13 @@ func startServer(ctx context.Context) func() error {
|
||||
core.WriteInitialMetrics()
|
||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
|
||||
}
|
||||
if conf.Server.DevEnableProfiler {
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
return a.Run(ctx, fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,11 +162,14 @@ func init() {
|
||||
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
|
||||
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
|
||||
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will use")
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)")
|
||||
rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)")
|
||||
rootCmd.Flags().String("tlskey", viper.GetString("tlskey"), "optional path to a TLS key file (enables HTTPS listening)")
|
||||
|
||||
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
|
||||
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
|
||||
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL (only the path part) to configure Navidrome behind a proxy (ex: /music)")
|
||||
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().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
@@ -174,9 +181,12 @@ func init() {
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
|
||||
_ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert"))
|
||||
_ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
|
||||
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
|
||||
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
|
||||
|
||||
_ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled"))
|
||||
|
||||
191
cmd/svc.go
Normal file
191
cmd/svc.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
svcStatusLabels = map[service.Status]string{
|
||||
service.StatusUnknown: "Unknown",
|
||||
service.StatusStopped: "Stopped",
|
||||
service.StatusRunning: "Running",
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
svcCmd.AddCommand(buildInstallCmd())
|
||||
svcCmd.AddCommand(buildUninstallCmd())
|
||||
svcCmd.AddCommand(buildStartCmd())
|
||||
svcCmd.AddCommand(buildStopCmd())
|
||||
svcCmd.AddCommand(buildStatusCmd())
|
||||
rootCmd.AddCommand(svcCmd)
|
||||
}
|
||||
|
||||
var svcCmd = &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc"},
|
||||
Short: "Manage Navidrome as a service",
|
||||
Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()),
|
||||
Run: runServiceCmd,
|
||||
}
|
||||
|
||||
type svcControl struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (p *svcControl) Start(_ service.Service) error {
|
||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||
go p.run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *svcControl) run() {
|
||||
runNavidrome(p.ctx)
|
||||
}
|
||||
|
||||
func (p *svcControl) Stop(_ service.Service) error {
|
||||
log.Info("Stopping service")
|
||||
p.cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
svc service.Service
|
||||
svcOnce = sync.Once{}
|
||||
)
|
||||
|
||||
func svcInstance() service.Service {
|
||||
svcOnce.Do(func() {
|
||||
options := make(service.KeyValue)
|
||||
options["Restart"] = "on-success"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["UserService"] = true
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
svcConfig := &service.Config{
|
||||
Name: "Navidrome",
|
||||
DisplayName: "Navidrome",
|
||||
Description: "Navidrome is a self-hosted music server and streamer",
|
||||
Dependencies: []string{
|
||||
"Requires=network.target",
|
||||
"After=network-online.target syslog.target"},
|
||||
WorkingDirectory: executablePath(),
|
||||
Option: options,
|
||||
}
|
||||
if conf.Server.ConfigFile != "" {
|
||||
svcConfig.Arguments = []string{"-c", conf.Server.ConfigFile}
|
||||
}
|
||||
prg := &svcControl{}
|
||||
var err error
|
||||
svc, err = service.New(prg, svcConfig)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
})
|
||||
return svc
|
||||
}
|
||||
|
||||
func runServiceCmd(cmd *cobra.Command, _ []string) {
|
||||
_ = cmd.Help()
|
||||
}
|
||||
|
||||
func executablePath() string {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return filepath.Dir(ex)
|
||||
}
|
||||
|
||||
func buildInstallCmd() *cobra.Command {
|
||||
runInstallCmd := func(_ *cobra.Command, _ []string) {
|
||||
var err error
|
||||
println("Installing service with:")
|
||||
println(" working directory: " + executablePath())
|
||||
println(" music folder: " + conf.Server.MusicFolder)
|
||||
println(" data folder: " + conf.Server.DataFolder)
|
||||
if cfgFile != "" {
|
||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println(" config file: " + conf.Server.ConfigFile)
|
||||
}
|
||||
err = svcInstance().Install()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service installed. Use 'navidrome svc start' to start it.")
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install Navidrome service.",
|
||||
Run: runInstallCmd,
|
||||
}
|
||||
}
|
||||
|
||||
func buildUninstallCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Uninstall Navidrome service. Does not delete the music or data folders",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := svcInstance().Uninstall()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service uninstalled. Music and data folders are still intact.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildStartCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start Navidrome service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := svcInstance().Start()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service started. Use 'navidrome svc status' to check its status.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildStopCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop Navidrome service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := svcInstance().Stop()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service stopped. Use 'navidrome svc status' to check its status.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show Navidrome service status",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
status, err := svcInstance().Status()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,16 @@ import (
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
serverServer := server.New(dataStore)
|
||||
broker := events.GetBroker()
|
||||
serverServer := server.New(dataStore, broker)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
share := core.NewShare(dataStore)
|
||||
router := nativeapi.New(dataStore, broker, share)
|
||||
router := nativeapi.New(dataStore, share)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
scanner := GetScanner()
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
share := core.NewShare(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share)
|
||||
return router
|
||||
}
|
||||
@@ -76,7 +76,8 @@ func CreatePublicRouter() *public.Router {
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package conf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -28,13 +29,21 @@ type configOptions struct {
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
BaseURL string
|
||||
BasePath string
|
||||
BaseHost string
|
||||
BaseScheme string
|
||||
TLSCert string
|
||||
TLSKey string
|
||||
UILoginBackgroundURL string
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
EnableArtworkPrecache bool
|
||||
AutoImportPlaylists bool
|
||||
PlaylistsPath string
|
||||
AutoTranscodeDownload bool
|
||||
@@ -44,15 +53,16 @@ type configOptions struct {
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
ProbeCommand string
|
||||
FFmpegPath string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
UIWelcomeMessage string
|
||||
ArtistArtPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
@@ -76,6 +86,7 @@ type configOptions struct {
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogSourceLine bool
|
||||
DevLogLevels map[string]string
|
||||
DevEnableProfiler bool
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
DevActivityPanel bool
|
||||
@@ -85,6 +96,8 @@ type configOptions struct {
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@@ -149,6 +162,19 @@ func Load() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.BasePath = u.Path
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
Server.BaseHost = u.Host
|
||||
Server.BaseScheme = u.Scheme
|
||||
}
|
||||
|
||||
// Print current configuration if log level is Debug
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
|
||||
@@ -223,10 +249,15 @@ func init() {
|
||||
viper.SetDefault("scaninterval", -1)
|
||||
viper.SetDefault("scanschedule", "@every 1m")
|
||||
viper.SetDefault("baseurl", "")
|
||||
viper.SetDefault("tlscert", "")
|
||||
viper.SetDefault("tlskey", "")
|
||||
viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("enableartworkprecache", true)
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
@@ -239,10 +270,10 @@ func init() {
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("subsonicartistparticipations", false)
|
||||
viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
@@ -250,7 +281,7 @@ func init() {
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("enablereplaygain", false)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
@@ -279,16 +310,20 @@ func init() {
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()))
|
||||
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
@@ -311,6 +346,7 @@ func InitConfig(cfgFile string) {
|
||||
err := viper.ReadInConfig()
|
||||
if viper.ConfigFileUsed() != "" && err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
// DefaultUILoginBackgroundOffline Background image used in case external integrations are disabled
|
||||
DefaultUILoginBackgroundOffline = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAABGdBTUEAALGPC/xhBQAAAiJJREFUeF7t0IEAAAAAw6D5Ux/khVBhwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDDwMDDVlwABBWcSrQAAAABJRU5ErkJggg=="
|
||||
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
|
||||
DefaultMaxSidebarPlaylists = 100
|
||||
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
@@ -115,7 +116,9 @@ var (
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
|
||||
UnknownAlbum = "[Unknown Album]"
|
||||
UnknownArtist = "[Unknown Artist]"
|
||||
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
|
||||
VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
|
||||
ServerStart = time.Now()
|
||||
|
||||
7
contrib/docker-compose/Caddyfile
Normal file
7
contrib/docker-compose/Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
https://your.website {
|
||||
reverse_proxy * navidrome:4533 {
|
||||
header_up Host {http.reverse_proxy.upstream.hostport}
|
||||
header_up X-Forwarded-For {http.request.remote}
|
||||
header_up X-Real-IP {http.reverse_proxy.upstream.port}
|
||||
}
|
||||
}
|
||||
31
contrib/docker-compose/docker-compose-caddy.yml
Normal file
31
contrib/docker-compose/docker-compose-caddy.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
version: '3.6'
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
navidrome_data:
|
||||
|
||||
services:
|
||||
|
||||
caddy:
|
||||
container_name: "caddy"
|
||||
image: caddy:2.6-alpine
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
volumes:
|
||||
- "caddy_data:/data:rw"
|
||||
- "./Caddyfile:/etc/caddy/Caddyfile:ro"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
navidrome:
|
||||
container_name: "navidrome"
|
||||
image: deluan/navidrome:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# user: 1000:1000
|
||||
ports:
|
||||
- "4533:4533"
|
||||
volumes:
|
||||
- "navidrome_data:/data"
|
||||
#- "/mnt/music:/music:ro"
|
||||
51
contrib/docker-compose/docker-compose-traefik.yml
Normal file
51
contrib/docker-compose/docker-compose-traefik.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
version: "3.6"
|
||||
|
||||
volumes:
|
||||
traefik_data:
|
||||
navidrome_data:
|
||||
|
||||
services:
|
||||
|
||||
traefik:
|
||||
container_name: "traefik"
|
||||
image: traefik:2.9
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
command:
|
||||
- "--log.level=ERROR"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.tc.acme.tlschallenge=true"
|
||||
#- "--certificatesresolvers.tc.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
- "--certificatesresolvers.tc.acme.email=foo@foo.com"
|
||||
- "--certificatesresolvers.tc.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- "traefik_data:/letsencrypt"
|
||||
#- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
|
||||
navidrome:
|
||||
container_name: "navidrome"
|
||||
image: deluan/navidrome:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# user: 1000:1000
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
ND_SCANINTERVAL: 6h
|
||||
ND_LOGLEVEL: info
|
||||
ND_SESSIONTIMEOUT: 168h
|
||||
ND_BASEURL: ""
|
||||
volumes:
|
||||
- "navidrome_data:/data"
|
||||
#- "/mnt/music:/music:ro"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.navidrome.rule=Host(`foo.com`)"
|
||||
- "traefik.http.routers.navidrome.entrypoints=websecure"
|
||||
- "traefik.http.routers.navidrome.tls=true"
|
||||
- "traefik.http.routers.navidrome.tls.certresolver=tc"
|
||||
- "traefik.http.services.navidrome.loadbalancer.server.port=4533"
|
||||
18
contrib/docker-compose/docker-compose.yml
Normal file
18
contrib/docker-compose/docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
version: '3.6'
|
||||
|
||||
volumes:
|
||||
navidrome_data:
|
||||
|
||||
services:
|
||||
|
||||
navidrome:
|
||||
container_name: "navidrome"
|
||||
image: deluan/navidrome:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
# user: 1000:1000
|
||||
ports:
|
||||
- "4533:4533"
|
||||
volumes:
|
||||
- "navidrome_data:/data"
|
||||
#- "/mnt/music:/music:ro"
|
||||
11
contrib/k8s/README.md
Normal file
11
contrib/k8s/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Kubernetes
|
||||
|
||||
A couple things to keep in mind with this manifest:
|
||||
|
||||
1. This creates a namespace called `navidrome`. Adjust this as needed.
|
||||
1. This manifest was created on [K3s](https://github.com/k3s-io/k3s), which uses its own storage provisioner called [local-path-provisioner](https://github.com/rancher/local-path-provisioner). Be sure to change the `storageClassName` of the `PersistentVolumeClaim` as needed.
|
||||
1. The `PersistentVolumeClaim` sets up a 2Gi volume for Navidrome's database. Adjust this as needed.
|
||||
1. Be sure to change the `image` tag from `ghcr.io/navidrome/navidrome:0.49.3` to whatever the newest version is.
|
||||
1. This assumes your music is mounted on the host using `hostPath` at `/path/to/your/music/on/the/host`. Adjust this as needed.
|
||||
1. The `Ingress` is already configured for `cert-manager` to obtain a Let's Encrypt TLS certificate and uses Traefik for routing. Adjust this as needed.
|
||||
1. The `Ingress` presents the service at `navidrome.${SECRET_INTERNAL_DOMAIN_NAME}`, which needs to already be setup in DNS.
|
||||
111
contrib/k8s/manifest.yml
Normal file
111
contrib/k8s/manifest.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: navidrome
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: navidrome-data-pvc
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
volumeType: local
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
storageClassName: local-path
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: navidrome-deployment
|
||||
namespace: navidrome
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: navidrome
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: navidrome
|
||||
spec:
|
||||
containers:
|
||||
- name: navidrome
|
||||
image: ghcr.io/navidrome/navidrome:0.49.3
|
||||
ports:
|
||||
- containerPort: 4533
|
||||
env:
|
||||
- name: ND_SCANSCHEDULE
|
||||
value: "12h"
|
||||
- name: ND_SESSIONTIMEOUT
|
||||
value: "24h"
|
||||
- name: ND_LOGLEVEL
|
||||
value: "info"
|
||||
- name: ND_ENABLETRANSCODINGCONFIG
|
||||
value: "false"
|
||||
- name: ND_TRANSCODINGCACHESIZE
|
||||
value: "512MB"
|
||||
- name: ND_ENABLESTARRATING
|
||||
value: "false"
|
||||
- name: ND_ENABLEFAVOURITES
|
||||
value: "false"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: music
|
||||
mountPath: /music
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: navidrome-data-pvc
|
||||
- name: music
|
||||
hostPath:
|
||||
path: /path/to/your/music/on/the/host
|
||||
type: Directory
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: navidrome-service
|
||||
namespace: navidrome
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
targetPort: 4533
|
||||
port: 4533
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: navidrome
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: navidrome-ingress
|
||||
namespace: navidrome
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-production
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
rules:
|
||||
- host: navidrome.${SECRET_INTERNAL_DOMAIN_NAME}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: navidrome-service
|
||||
port:
|
||||
number: 4533
|
||||
tls:
|
||||
- hosts:
|
||||
- navidrome.${SECRET_INTERNAL_DOMAIN_NAME}
|
||||
secretName: navidrome-tls
|
||||
@@ -38,6 +38,7 @@ RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallFilter=setrlimit
|
||||
SystemCallArchitectures=native
|
||||
UMask=0066
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
@@ -42,6 +42,12 @@ func (a *Agents) AgentName() string {
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -61,6 +67,12 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -80,6 +92,12 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -90,7 +108,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||
continue
|
||||
}
|
||||
bio, err := agent.GetArtistBiography(ctx, id, name, mbid)
|
||||
if bio != "" && err == nil {
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, nil
|
||||
}
|
||||
@@ -99,6 +117,12 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||
}
|
||||
|
||||
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -122,6 +146,12 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -141,6 +171,12 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -160,6 +196,9 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||
}
|
||||
|
||||
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||
if name == consts.UnknownAlbum {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, ag := range a.agents {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
@@ -61,6 +62,18 @@ var _ = Describe("Agents", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistMBID(ctx, "123", "test")
|
||||
@@ -80,6 +93,18 @@ var _ = Describe("Agents", func() {
|
||||
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
|
||||
@@ -99,6 +124,18 @@ var _ = Describe("Agents", func() {
|
||||
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@@ -21,6 +22,11 @@ const (
|
||||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
var ignoredBiographies = []string{
|
||||
// Unknown Artist
|
||||
`<a href="https://www.last.fm/music/`,
|
||||
}
|
||||
|
||||
type lastfmAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
@@ -124,9 +130,15 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
|
||||
if a.Bio.Summary == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
for _, ign := range ignoredBiographies {
|
||||
if strings.HasPrefix(a.Bio.Summary, ign) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
||||
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -151,6 +151,7 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
|
||||
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
|
||||
|
||||
@@ -87,6 +87,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response interface{}) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -18,16 +18,18 @@ import (
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipShare(ctx context.Context, id string, w io.Writer) error
|
||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore) Archiver {
|
||||
return &archiver{ds: ds, ms: ms}
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
shares Share
|
||||
}
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
@@ -69,7 +71,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||
func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||
z := zip.NewWriter(out)
|
||||
comment := "Downloaded from Navidrome"
|
||||
if format != "raw" {
|
||||
if format != "raw" && format != "" {
|
||||
comment = fmt.Sprintf("%s, transcoded to %s %dbps", comment, format, bitrate)
|
||||
}
|
||||
_ = z.SetComment(comment)
|
||||
@@ -87,19 +89,31 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultDisc b
|
||||
return fmt.Sprintf("%s/%s", mf.Album, file)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
||||
s, err := a.shares.Load(ctx, id)
|
||||
if !s.Downloadable {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
return a.zipPlaylist(ctx, id, format, bitrate, out, pls)
|
||||
}
|
||||
|
||||
func (a *archiver) zipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer, pls *model.Playlist) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
@@ -113,7 +127,7 @@ func (a *archiver) zipPlaylist(ctx context.Context, id string, format string, bi
|
||||
|
||||
func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) string {
|
||||
ext := mf.Suffix
|
||||
if format != "raw" {
|
||||
if format != "" && format != "raw" {
|
||||
ext = format
|
||||
}
|
||||
file := fmt.Sprintf("%02d - %s - %s.%s", idx+1, mf.Artist, mf.Title, ext)
|
||||
@@ -132,7 +146,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" {
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
|
||||
} else {
|
||||
r, err = os.Open(mf.Path)
|
||||
|
||||
211
core/archiver_test.go
Normal file
211
core/archiver_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Archiver", func() {
|
||||
var (
|
||||
arch core.Archiver
|
||||
ms *mockMediaStreamer
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
ds = &mockDataStore{}
|
||||
sh = &mockShare{}
|
||||
arch = core.NewArchiver(ms, ds, sh)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
It("zips an album correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipArtist", func() {
|
||||
It("zips an artist's albums correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_artist_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||
{ID: "2", Path: "test_data/02 - track2.mp3", Suffix: "mp3", Artist: "Artist 2", Title: "track2"},
|
||||
}
|
||||
|
||||
share := &model.Share{
|
||||
ID: "1",
|
||||
Downloadable: true,
|
||||
Format: "mp3",
|
||||
MaxBitRate: 128,
|
||||
Tracks: mfs,
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipPlaylist", func() {
|
||||
It("zips a playlist correctly", func() {
|
||||
tracks := []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 1", Title: "track1"}},
|
||||
{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
|
||||
}
|
||||
|
||||
pls := &model.Playlist{
|
||||
ID: "1",
|
||||
Name: "Test Playlist",
|
||||
Tracks: tracks,
|
||||
}
|
||||
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockDataStore struct {
|
||||
mock.Mock
|
||||
model.DataStore
|
||||
}
|
||||
|
||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(model.MediaFileRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(model.PlaylistRepository)
|
||||
}
|
||||
|
||||
type mockMediaFileRepository struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
args := m.Called(options)
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
type mockPlaylistRepository struct {
|
||||
mock.Mock
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (m *mockPlaylistRepository) GetWithTracks(id string, includeTracks bool) (*model.Playlist, error) {
|
||||
args := m.Called(id, includeTracks)
|
||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||
}
|
||||
|
||||
type mockMediaStreamer struct {
|
||||
mock.Mock
|
||||
core.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, format string, bitrate int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, format, bitrate)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
mock.Mock
|
||||
core.Share
|
||||
}
|
||||
|
||||
func (m *mockShare) Load(ctx context.Context, id string) (*model.Share, error) {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Get(0).(*model.Share), args.Error(1)
|
||||
}
|
||||
@@ -7,16 +7,21 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
|
||||
@@ -36,12 +41,23 @@ type artworkReader interface {
|
||||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
if err == nil {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size)
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
} else {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
return reader, consts.ServerStart, nil
|
||||
}
|
||||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
@@ -49,17 +65,21 @@ func (a *artwork) Get(ctx context.Context, id string, size int) (reader io.ReadC
|
||||
|
||||
r, err := a.cache.Get(ctx, artReader)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", id, "size", size, err)
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
|
||||
}
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return r, artReader.LastUpdated(), nil
|
||||
}
|
||||
|
||||
type coverArtGetter interface {
|
||||
CoverArtID() model.ArtworkID
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
if id == "" {
|
||||
return model.ArtworkID{}, nil
|
||||
return model.ArtworkID{}, ErrUnavailable
|
||||
}
|
||||
artID, err := model.ParseArtworkID(id)
|
||||
if err == nil {
|
||||
@@ -71,18 +91,17 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
|
||||
if err != nil {
|
||||
return model.ArtworkID{}, err
|
||||
}
|
||||
if e, ok := entity.(coverArtGetter); ok {
|
||||
artID = e.CoverArtID()
|
||||
}
|
||||
switch e := entity.(type) {
|
||||
case *model.Artist:
|
||||
artID = model.NewArtworkID(model.KindArtistArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
|
||||
case *model.Album:
|
||||
artID = model.NewArtworkID(model.KindAlbumArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
|
||||
case *model.MediaFile:
|
||||
artID = model.NewArtworkID(model.KindMediaFileArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
|
||||
case *model.Playlist:
|
||||
artID = model.NewArtworkID(model.KindPlaylistArtwork, e.ID)
|
||||
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
|
||||
}
|
||||
return artID, nil
|
||||
@@ -104,7 +123,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
artReader, err = newEmptyIDReader(ctx, artID)
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
}
|
||||
return artReader, err
|
||||
|
||||
@@ -22,7 +22,8 @@ var _ = Describe("Artwork", func() {
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var mfWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
@@ -30,14 +31,23 @@ var _ = Describe("Artwork", func() {
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/test.mp3"}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/front.png"}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
|
||||
alMultipleCovers = model.Album{ID: "666", Name: "All options", EmbedArtPath: "tests/fixtures/test.mp3",
|
||||
ImageFiles: "tests/fixtures/cover.jpg:tests/fixtures/front.png",
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
Paths: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
|
||||
"tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
|
||||
"tests/fixtures/artist/an-album/artist.png",
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
|
||||
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
|
||||
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
|
||||
|
||||
@@ -49,7 +59,7 @@ var _ = Describe("Artwork", func() {
|
||||
Describe("albumArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if album is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT_FOUND"), nil)
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
@@ -65,15 +75,14 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
|
||||
})
|
||||
It("returns placeholder if embed path is not available", func() {
|
||||
It("returns ErrUnavailable if embed path is not available", func() {
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(consts.PlaceholderAlbumArt))
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("External images", func() {
|
||||
@@ -88,14 +97,13 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/front.png"))
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns placeholder if external file is not available", func() {
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(consts.PlaceholderAlbumArt))
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
@@ -113,9 +121,36 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/front.png"),
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/test.mp3"),
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfAnotherWithEmbed,
|
||||
})
|
||||
})
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
||||
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -159,14 +194,14 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444"))
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("returns album cover if media file has no cover art", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444"))
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -178,7 +213,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID().String(), 15)
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
@@ -192,7 +227,7 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID().String(), 200)
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
br, format, err := asImageReader(r)
|
||||
|
||||
@@ -28,20 +28,30 @@ var _ = Describe("Artwork", func() {
|
||||
aw = artwork.NewArtwork(ds, cache, ffmpeg, nil)
|
||||
})
|
||||
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.Get(context.Background(), "", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(result).To(Equal(phBytes))
|
||||
Expect(result).To(Equal(phBytes))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,14 +23,14 @@ type CacheWarmer interface {
|
||||
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" {
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[string]struct{}),
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
@@ -42,16 +42,24 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[string]struct{}
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
}
|
||||
|
||||
var ignoredIds = map[string]struct{}{
|
||||
consts.VariousArtistsID: {},
|
||||
consts.UnknownArtistID: {},
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if _, shouldIgnore := ignoredIds[artID.ID]; shouldIgnore {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID.String()] = struct{}{}
|
||||
a.buffer[artID] = struct{}{}
|
||||
a.sendWakeSignal()
|
||||
}
|
||||
|
||||
@@ -87,7 +95,7 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
}
|
||||
|
||||
batch := maps.Keys(a.buffer)
|
||||
a.buffer = make(map[string]struct{})
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
a.processBatch(ctx, batch)
|
||||
@@ -108,7 +116,7 @@ func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []string) {
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
@@ -117,7 +125,7 @@ func (a *cacheWarmer) processBatch(ctx context.Context, batch []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id string) error {
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ type cacheKey struct {
|
||||
|
||||
func (k *cacheKey) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s.%d",
|
||||
k.artID,
|
||||
"%s-%s.%d",
|
||||
k.artID.Kind,
|
||||
k.artID.ID,
|
||||
k.lastUpdate.UnixMilli(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ func (a *albumArtworkReader) LastUpdated() time.Time {
|
||||
|
||||
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
|
||||
ff = append(ff, fromAlbumPlaceholder())
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -42,17 +43,19 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
||||
em: em,
|
||||
artist: *ar,
|
||||
}
|
||||
a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
|
||||
// change _after_ retrieving from external sources, making the key invalid
|
||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
var files []string
|
||||
var paths []string
|
||||
for _, al := range als {
|
||||
files = append(files, al.ImageFiles)
|
||||
paths = append(paths, filepath.SplitList(al.Paths)...)
|
||||
paths = append(paths, splitList(al.Paths)...)
|
||||
if a.cacheKey.lastUpdate.Before(al.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
}
|
||||
}
|
||||
a.files = strings.Join(files, string(filepath.ListSeparator))
|
||||
a.files = strings.Join(files, consts.Zwsp)
|
||||
a.artistFolder = utils.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(a.artistFolder, string(filepath.Separator)) {
|
||||
a.artistFolder, _ = filepath.Split(a.artistFolder)
|
||||
@@ -64,10 +67,10 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
||||
func (a *artistReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
|
||||
return fmt.Sprintf(
|
||||
"%s.%x.%t",
|
||||
"%s.%t.%x",
|
||||
a.cacheKey.Key(),
|
||||
hash,
|
||||
conf.Server.EnableExternalServices,
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,12 +79,24 @@ func (a *artistReader) LastUpdated() time.Time {
|
||||
}
|
||||
|
||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID,
|
||||
fromArtistFolder(ctx, a.artistFolder, "artist.*"),
|
||||
fromExternalFile(ctx, a.files, "artist.*"),
|
||||
fromArtistExternalSource(ctx, a.artist, a.em),
|
||||
fromArtistPlaceholder(),
|
||||
)
|
||||
var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.files, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
@@ -95,12 +110,18 @@ func fromArtistFolder(ctx context.Context, artistFolder string, pattern string)
|
||||
if len(matches) == 0 {
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder)
|
||||
}
|
||||
filePath := filepath.Join(artistFolder, matches[0])
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
return nil, "", err
|
||||
for _, m := range matches {
|
||||
filePath := filepath.Join(artistFolder, m)
|
||||
if !model.IsImageFile(m) {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
return nil, "", err
|
||||
}
|
||||
return f, filePath, nil
|
||||
}
|
||||
return f, filePath, err
|
||||
return nil, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type emptyIDReader struct {
|
||||
artID model.ArtworkID
|
||||
}
|
||||
|
||||
func newEmptyIDReader(_ context.Context, artID model.ArtworkID) (*emptyIDReader, error) {
|
||||
a := &emptyIDReader{
|
||||
artID: artID,
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *emptyIDReader) LastUpdated() time.Time {
|
||||
return consts.ServerStart // Invalidate cached placeholder every server start
|
||||
}
|
||||
|
||||
func (a *emptyIDReader) Key() string {
|
||||
return fmt.Sprintf("placeholder.%d.0.%d", a.LastUpdated().UnixMilli(), conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (a *emptyIDReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
return selectImageReader(ctx, a.artID, fromAlbumPlaceholder())
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
|
||||
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
// Get artwork in original size, possibly from cache
|
||||
orig, _, err := a.a.Get(ctx, a.artID.String(), 0)
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
|
||||
}
|
||||
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get a cover art for %s", artID)
|
||||
return nil, "", fmt.Errorf("could not get a cover art for %s: %w", artID, ErrUnavailable)
|
||||
}
|
||||
|
||||
type sourceFunc func() (r io.ReadCloser, path string, err error)
|
||||
@@ -52,9 +52,13 @@ func (f sourceFunc) String() string {
|
||||
return name
|
||||
}
|
||||
|
||||
func splitList(s string) []string {
|
||||
return strings.Split(s, consts.Zwsp)
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, files string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range filepath.SplitList(files) {
|
||||
for _, file := range splitList(files) {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
if err != nil {
|
||||
@@ -120,7 +124,7 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
||||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id.String(), 0)
|
||||
r, _, err := a.Get(ctx, id, 0)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -134,14 +138,6 @@ func fromAlbumPlaceholder() sourceFunc {
|
||||
return r, consts.PlaceholderAlbumArt, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromArtistPlaceholder() sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _ := resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
return r, consts.PlaceholderArtistArt, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := em.ArtistImage(ctx, ar.ID)
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
@@ -20,11 +19,15 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
unavailableArtistID = "-1"
|
||||
maxSimilarArtists = 100
|
||||
refreshDelay = 5 * time.Second
|
||||
refreshTimeout = 15 * time.Second
|
||||
refreshQueueLength = 2000
|
||||
)
|
||||
|
||||
type ExternalMetadata interface {
|
||||
@@ -37,8 +40,10 @@ type ExternalMetadata interface {
|
||||
}
|
||||
|
||||
type externalMetadata struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
artistQueue chan<- *auxArtist
|
||||
albumQueue chan<- *auxAlbum
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
@@ -52,7 +57,10 @@ type auxArtist struct {
|
||||
}
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
return &externalMetadata{ds: ds, ag: agents}
|
||||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum, error) {
|
||||
@@ -84,33 +92,29 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
|
||||
if album.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
|
||||
err = e.refreshAlbumInfo(ctx, album)
|
||||
err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if time.Since(album.ExternalInfoUpdatedAt) > consts.AlbumInfoTimeToLive {
|
||||
if time.Since(album.ExternalInfoUpdatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
err := e.refreshAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", id, "name", album.Name, err)
|
||||
}
|
||||
}()
|
||||
enqueueRefresh(e.albumQueue, album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshAlbumInfo(ctx context.Context, album *auxAlbum) error {
|
||||
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbum) error {
|
||||
start := time.Now()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
|
||||
"elapsed", time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -139,10 +143,12 @@ func (e *externalMetadata) refreshAlbumInfo(ctx context.Context, album *auxAlbum
|
||||
|
||||
err = e.ds.Album(ctx).Put(&album.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name, err)
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -180,6 +186,16 @@ func clearName(name string) string {
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*auxArtist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,30 +204,22 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
|
||||
// If we don't have any info, retrieves it now
|
||||
if artist.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
err = e.refreshArtistInfo(ctx, artist)
|
||||
err := e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a refresh in the background
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) > consts.ArtistInfoTimeToLive {
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
err := e.refreshArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
log.Error("Error refreshing ArtistInfo", "id", id, "name", artist.Name, err)
|
||||
}
|
||||
}()
|
||||
enqueueRefresh(e.artistQueue, artist)
|
||||
}
|
||||
|
||||
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArtist) error {
|
||||
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxArtist) error {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
|
||||
@@ -221,40 +229,30 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArt
|
||||
}
|
||||
|
||||
// Call all registered agents and collect information
|
||||
callParallel([]func(){
|
||||
func() { e.callGetBiography(ctx, e.ag, artist) },
|
||||
func() { e.callGetURL(ctx, e.ag, artist) },
|
||||
func() { e.callGetImage(ctx, e.ag, artist) },
|
||||
func() { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true) },
|
||||
})
|
||||
g := errgroup.Group{}
|
||||
g.SetLimit(2)
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true); return nil })
|
||||
_ = g.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", ctx.Err())
|
||||
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = time.Now()
|
||||
err := e.ds.Artist(ctx).Put(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name, err)
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist)
|
||||
return nil
|
||||
}
|
||||
|
||||
func callParallel(fs []func()) {
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(len(fs))
|
||||
for _, f := range fs {
|
||||
go func(f func()) {
|
||||
f()
|
||||
wg.Done()
|
||||
}(f)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
@@ -426,16 +424,16 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
url, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if url == "" || err != nil {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
artist.ExternalUrl = url
|
||||
artist.ExternalUrl = artisURL
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
if bio == "" || err != nil {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bio = utils.SanitizeText(bio)
|
||||
@@ -445,7 +443,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
|
||||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if len(images) == 0 || err != nil {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
@@ -467,7 +465,9 @@ func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.Arti
|
||||
if len(similar) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent)
|
||||
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -558,3 +558,29 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
|
||||
artist.SimilarArtists = loaded
|
||||
return nil
|
||||
}
|
||||
|
||||
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
|
||||
queue := make(chan T, refreshQueueLength)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(refreshDelay)
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case a := <-queue:
|
||||
_ = processFn(ctx, a)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
return queue
|
||||
}
|
||||
|
||||
func enqueueRefresh[T any](queue chan<- T, item T) {
|
||||
select {
|
||||
case queue <- item:
|
||||
default: // It is ok to miss a refresh
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,64 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
// TODO Move scanner ffmpeg probe to here
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
CmdPath() (string, error)
|
||||
}
|
||||
|
||||
func New() FFmpeg {
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
const extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(command, path, maxBitRate)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(extractImageCmd, path, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := createProbeCommand(probeCmd, files)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
||||
output, _ := cmd.CombinedOutput()
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) CmdPath() (string, error) {
|
||||
return ffmpegCmd()
|
||||
}
|
||||
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &Cmd{ctx: ctx, args: args}
|
||||
j := &ffCmd{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start()
|
||||
if err != nil {
|
||||
@@ -49,16 +76,15 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
|
||||
return j, nil
|
||||
}
|
||||
|
||||
type Cmd struct {
|
||||
type ffCmd struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
ctx context.Context
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *Cmd) start() error {
|
||||
cmd := exec.CommandContext(j.ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
func (j *ffCmd) start() error {
|
||||
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -73,7 +99,7 @@ func (j *Cmd) start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *Cmd) wait() {
|
||||
func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
@@ -83,16 +109,12 @@ func (j *Cmd) wait() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if j.ctx.Err() != nil {
|
||||
_ = j.out.CloseWithError(j.ctx.Err())
|
||||
return
|
||||
}
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
|
||||
split := strings.Split(cmd, " ")
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
@@ -101,3 +123,59 @@ func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
|
||||
|
||||
return split
|
||||
}
|
||||
|
||||
func createProbeCommand(cmd string, inputs []string) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
var args []string
|
||||
|
||||
for _, s := range split {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
args = append(args, "-i", inp)
|
||||
}
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func fixCmd(cmd string) string {
|
||||
split := strings.Split(cmd, " ")
|
||||
var result []string
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
for _, s := range split {
|
||||
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
||||
result = append(result, cmdPath)
|
||||
} else {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
func ffmpegCmd() (string, error) {
|
||||
ffOnce.Do(func() {
|
||||
if conf.Server.FFmpegPath != "" {
|
||||
ffmpegPath = conf.Server.FFmpegPath
|
||||
ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath)
|
||||
} else {
|
||||
ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg")
|
||||
if errors.Is(ffmpegErr, exec.ErrDot) {
|
||||
log.Trace("ffmpeg found in current folder '.'")
|
||||
ffmpegPath, ffmpegErr = exec.LookPath("./ffmpeg")
|
||||
}
|
||||
}
|
||||
if ffmpegErr == nil {
|
||||
log.Info("Found ffmpeg", "path", ffmpegPath)
|
||||
return
|
||||
}
|
||||
})
|
||||
return ffmpegPath, ffmpegErr
|
||||
}
|
||||
|
||||
var (
|
||||
ffOnce sync.Once
|
||||
ffmpegPath string
|
||||
ffmpegErr error
|
||||
)
|
||||
|
||||
@@ -16,9 +16,23 @@ func TestFFmpeg(t *testing.T) {
|
||||
RunSpecs(t, "FFmpeg Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("createFFmpegCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
var _ = Describe("ffmpeg", func() {
|
||||
BeforeEach(func() {
|
||||
_, _ = ffmpegCmd()
|
||||
ffmpegPath = "ffmpeg"
|
||||
ffmpegErr = nil
|
||||
})
|
||||
Describe("createFFmpegCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createProbeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
)
|
||||
|
||||
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
||||
@@ -57,7 +56,12 @@ func (b *bufferedScrobbler) run(ctx context.Context) {
|
||||
b.sendWakeSignal()
|
||||
})
|
||||
}
|
||||
<-pl.ReadOrDone(ctx, b.wakeSignal)
|
||||
select {
|
||||
case <-b.wakeSignal:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
const nowPlayingExpire = 60 * time.Minute
|
||||
const maxNowPlayingExpire = 60 * time.Minute
|
||||
|
||||
type NowPlayingInfo struct {
|
||||
TrackID string
|
||||
MediaFile model.MediaFile
|
||||
Start time.Time
|
||||
Username string
|
||||
PlayerId string
|
||||
@@ -46,50 +46,58 @@ type playTracker struct {
|
||||
|
||||
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
||||
return singleton.GetInstance(func() *playTracker {
|
||||
m := ttlcache.NewCache()
|
||||
m.SkipTTLExtensionOnHit(true)
|
||||
_ = m.SetTTL(nowPlayingExpire)
|
||||
p := &playTracker{ds: ds, playMap: m, broker: broker}
|
||||
p.scrobblers = make(map[string]Scrobbler)
|
||||
for name, constructor := range constructors {
|
||||
s := constructor(ds)
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
}
|
||||
p.scrobblers[name] = s
|
||||
}
|
||||
return p
|
||||
return newPlayTracker(ds, broker)
|
||||
})
|
||||
}
|
||||
|
||||
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
|
||||
// the GetPlayTracker function above
|
||||
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||
m := ttlcache.NewCache()
|
||||
m.SkipTTLExtensionOnHit(true)
|
||||
_ = m.SetTTL(maxNowPlayingExpire)
|
||||
p := &playTracker{ds: ds, playMap: m, broker: broker}
|
||||
p.scrobblers = make(map[string]Scrobbler)
|
||||
for name, constructor := range constructors {
|
||||
s := constructor(ds)
|
||||
if conf.Server.DevEnableBufferedScrobble {
|
||||
s = newBufferedScrobbler(ds, s, name)
|
||||
}
|
||||
p.scrobblers[name] = s
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
|
||||
mf, err := p.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||
return err
|
||||
}
|
||||
|
||||
user, _ := request.UserFrom(ctx)
|
||||
info := NowPlayingInfo{
|
||||
TrackID: trackId,
|
||||
MediaFile: *mf,
|
||||
Start: time.Now(),
|
||||
Username: user.UserName,
|
||||
PlayerId: playerId,
|
||||
PlayerName: playerName,
|
||||
}
|
||||
_ = p.playMap.Set(playerId, info)
|
||||
|
||||
ttl := time.Duration(int(mf.Duration)+5) * time.Second
|
||||
_ = p.playMap.SetWithTTL(playerId, info, ttl)
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
p.dispatchNowPlaying(ctx, user.ID, trackId)
|
||||
p.dispatchNowPlaying(ctx, user.ID, mf)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, trackId string) {
|
||||
t, err := p.ds.MediaFile(ctx).Get(trackId)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
|
||||
return
|
||||
}
|
||||
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) {
|
||||
if t.Artist == consts.UnknownArtist {
|
||||
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
|
||||
return
|
||||
}
|
||||
// TODO Parallelize
|
||||
for name, s := range p.scrobblers {
|
||||
if !s.IsAuthorized(ctx, userId) {
|
||||
continue
|
||||
@@ -103,7 +111,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra
|
||||
}
|
||||
}
|
||||
|
||||
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) {
|
||||
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
|
||||
var res []NowPlayingInfo
|
||||
for _, playerId := range p.playMap.GetKeys() {
|
||||
value, err := p.playMap.Get(playerId)
|
||||
|
||||
@@ -37,7 +37,7 @@ var _ = Describe("PlayTracker", func() {
|
||||
Register("fake", func(ds model.DataStore) Scrobbler {
|
||||
return &fake
|
||||
})
|
||||
tracker = GetPlayTracker(ds, events.GetBroker())
|
||||
tracker = newPlayTracker(ds, events.GetBroker())
|
||||
|
||||
track = model.MediaFile{
|
||||
ID: "123",
|
||||
@@ -93,16 +93,13 @@ var _ = Describe("PlayTracker", func() {
|
||||
})
|
||||
|
||||
Describe("GetNowPlaying", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
})
|
||||
It("returns current playing music", func() {
|
||||
track2 := track
|
||||
track2.ID = "456"
|
||||
_ = ds.MediaFile(ctx).Put(&track)
|
||||
ctx = request.WithUser(ctx, model.User{UserName: "user-1"})
|
||||
_ = ds.MediaFile(ctx).Put(&track2)
|
||||
ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
|
||||
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123")
|
||||
ctx = request.WithUser(ctx, model.User{UserName: "user-2"})
|
||||
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
|
||||
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456")
|
||||
|
||||
playing, err := tracker.GetNowPlaying(ctx)
|
||||
@@ -112,12 +109,12 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(playing[0].PlayerId).To(Equal("player-2"))
|
||||
Expect(playing[0].PlayerName).To(Equal("player-two"))
|
||||
Expect(playing[0].Username).To(Equal("user-2"))
|
||||
Expect(playing[0].TrackID).To(Equal("456"))
|
||||
Expect(playing[0].MediaFile.ID).To(Equal("456"))
|
||||
|
||||
Expect(playing[1].PlayerId).To(Equal("player-1"))
|
||||
Expect(playing[1].PlayerName).To(Equal("player-one"))
|
||||
Expect(playing[1].Username).To(Equal("user-1"))
|
||||
Expect(playing[1].TrackID).To(Equal("123"))
|
||||
Expect(playing[1].MediaFile.ID).To(Equal("123"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
|
||||
return nil, err
|
||||
}
|
||||
if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) {
|
||||
return nil, model.ErrNotAvailable
|
||||
return nil, model.ErrExpired
|
||||
}
|
||||
share.LastVisitedAt = time.Now()
|
||||
share.VisitCount++
|
||||
@@ -125,7 +125,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
cols := []string{"description"}
|
||||
cols := []string{"description", "downloadable"}
|
||||
|
||||
// TODO Better handling of Share expiration
|
||||
if !entity.(*model.Share).ExpiresAt.IsZero() {
|
||||
|
||||
@@ -45,7 +45,7 @@ var _ = Describe("Share", func() {
|
||||
entity := &model.Share{}
|
||||
err := repo.Update("id", entity)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description"))
|
||||
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description", "downloadable"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
15
db/db.go
15
db/db.go
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
_ "github.com/navidrome/navidrome/db/migration"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,6 +18,11 @@ var (
|
||||
Path string
|
||||
)
|
||||
|
||||
//go:embed migration/*.sql
|
||||
var embedMigrations embed.FS
|
||||
|
||||
const migrationsFolder = "migration"
|
||||
|
||||
func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
Path = conf.Server.DbPath
|
||||
@@ -38,7 +44,7 @@ func Close() error {
|
||||
return Db().Close()
|
||||
}
|
||||
|
||||
func EnsureLatestVersion() {
|
||||
func Init() {
|
||||
db := Db()
|
||||
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
@@ -55,18 +61,19 @@ func EnsureLatestVersion() {
|
||||
|
||||
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
||||
goose.SetLogger(gooseLogger)
|
||||
goose.SetBaseFS(embedMigrations)
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
if err != nil {
|
||||
log.Fatal("Invalid DB driver", "driver", Driver, err)
|
||||
}
|
||||
err = goose.Run("up", db, "./")
|
||||
err = goose.Up(db, migrationsFolder)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to apply new migrations", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isSchemaEmpty(db *sql.DB) bool { // nolint:interfacer
|
||||
func isSchemaEmpty(db *sql.DB) bool {
|
||||
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
|
||||
if err != nil {
|
||||
log.Fatal("Database could not be opened!", err)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package migrations
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user