mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 03:18:13 -05:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf98af9da | ||
|
|
40f7170930 | ||
|
|
4a8f176f0d | ||
|
|
4507895d58 | ||
|
|
90c2d7d1cf | ||
|
|
f62231f728 | ||
|
|
fd1e06049f | ||
|
|
7dfe29af5e | ||
|
|
8e03d7d013 | ||
|
|
960415ed95 | ||
|
|
ea231fe265 | ||
|
|
09e52eba87 | ||
|
|
2650e4c27c | ||
|
|
3c0f23e3f2 | ||
|
|
250b6dbb33 | ||
|
|
be945e010a | ||
|
|
9308127342 | ||
|
|
141edb881e | ||
|
|
20a1e3160b | ||
|
|
d4c458d193 | ||
|
|
ed87e703ff | ||
|
|
dcb5725642 |
@@ -2,7 +2,7 @@
|
||||
|
||||
# [Choice] Go version: 1, 1.15, 1.14
|
||||
ARG VARIANT="1"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
|
||||
|
||||
# [Option] Install Node.js
|
||||
ARG INSTALL_NODE="true"
|
||||
@@ -17,4 +17,4 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# RUN go get -x <your-dependency-or-tool>
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
@@ -4,10 +4,10 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.23",
|
||||
"VARIANT": "1.20",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v20"
|
||||
"NODE_VERSION": "v18"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
@@ -18,37 +18,33 @@
|
||||
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
|
||||
],
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"go.useGoProxyToCheckForToolUpdates": false,
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"go.goroot": "/usr/local/go",
|
||||
"go.toolsGopath": "/go/bin",
|
||||
"go.formatTool": "goimports",
|
||||
"go.lintOnSave": "package",
|
||||
"go.lintTool": "golangci-lint",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go",
|
||||
"esbenp.prettier-vscode",
|
||||
"tamasfe.even-better-toml"
|
||||
]
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"go.useGoProxyToCheckForToolUpdates": false,
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"go.goroot": "/usr/local/go",
|
||||
"go.toolsGopath": "/go/bin",
|
||||
"go.formatTool": "goimports",
|
||||
"go.lintOnSave": "package",
|
||||
"go.lintTool": "golangci-lint",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go",
|
||||
"esbenp.prettier-vscode",
|
||||
"tamasfe.even-better-toml"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
4533,
|
||||
|
||||
8
.github/workflows/pipeline.dockerfile
vendored
8
.github/workflows/pipeline.dockerfile
vendored
@@ -16,13 +16,11 @@ RUN chmod +x /navidrome
|
||||
|
||||
#####################################################
|
||||
### Build Final Image
|
||||
FROM alpine:3.18
|
||||
FROM alpine as release
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and mpv
|
||||
RUN apk add -U --no-cache ffmpeg mpv
|
||||
|
||||
# Show ffmpeg build info, for troubleshooting purposes
|
||||
# Install ffmpeg and output build config
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN ffmpeg -buildconf
|
||||
|
||||
COPY --from=copy-binary /navidrome /app/
|
||||
|
||||
152
.github/workflows/pipeline.yml
vendored
152
.github/workflows/pipeline.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: "Pipeline: Test, Lint, Build"
|
||||
name: 'Pipeline: Test, Lint, Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -13,22 +13,26 @@ jobs:
|
||||
go-lint:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
- name: Set up Go 1.20
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.20.x
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --timeout 2m
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
run: go install golang.org/x/tools/cmd/goimports
|
||||
|
||||
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
|
||||
- run: go mod tidy
|
||||
@@ -36,20 +40,50 @@ jobs:
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo 'To fix this check, run "make format" and commit the changes'
|
||||
echo 'To fix this check, run "goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
- name: Build and Lint OpenAPI spec
|
||||
run: make lintapi gen
|
||||
|
||||
- name: Verify no changes
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo 'Changes to OpenAPI spec caused changes to the code. Please review and commit the changes.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: openapi.yaml
|
||||
path: api/openapi.yaml
|
||||
|
||||
go:
|
||||
name: Test with Go ${{ matrix.go_version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go_version: [1.20.x,1.19.x]
|
||||
steps:
|
||||
- name: Install taglib
|
||||
run: sudo apt-get install libtag1-dev
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go ${{ matrix.go_version }}
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go_version }}
|
||||
cache: true
|
||||
|
||||
- name: Download dependencies
|
||||
if: steps.cache-go.outputs.cache-hit != 'true'
|
||||
@@ -64,14 +98,14 @@ jobs:
|
||||
name: Build JS bundle
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: npm install dependencies
|
||||
run: |
|
||||
@@ -93,68 +127,56 @@ jobs:
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
retention-days: 7
|
||||
|
||||
i18n-lint:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
echo "Validating $file"
|
||||
if ! jq empty "$file" 2>error.log; then
|
||||
error_message=$(cat error.log)
|
||||
line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+')
|
||||
echo "::error file=$file,line=$line_number::$error_message"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
binaries:
|
||||
name: Build binaries
|
||||
needs: [js, go, go-lint, i18n-lint]
|
||||
needs: [js, go, go-lint]
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
run: goreleaser release --clean --skip=publish --snapshot
|
||||
- name: Config /github/workspace folder as trusted
|
||||
uses: docker://deluan/ci-goreleaser:1.20.3-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
uses: docker://deluan/ci-goreleaser:1.20.3-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist --skip-publish --snapshot
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: goreleaser release --clean
|
||||
uses: docker://deluan/ci-goreleaser:1.20.3-1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: goreleaser release --rm-dist
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: binaries
|
||||
path: |
|
||||
dist
|
||||
!dist/*.tar.gz
|
||||
!dist/*.zip
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Build and publish Docker images
|
||||
@@ -165,18 +187,18 @@ jobs:
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v2
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v2
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
with:
|
||||
name: binaries
|
||||
@@ -184,14 +206,14 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v3
|
||||
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@v3
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -200,12 +222,12 @@ jobs:
|
||||
- name: Extract metadata for Docker
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
labels: |
|
||||
maintainer=deluan
|
||||
images: |
|
||||
name=${{secrets.DOCKER_IMAGE}}
|
||||
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
|
||||
@@ -214,7 +236,7 @@ jobs:
|
||||
|
||||
- name: Build and Push
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: .github/workflows/pipeline.dockerfile
|
||||
|
||||
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@@ -12,9 +12,8 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v4.0.0
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
pr-inactive-days: 120
|
||||
log-output: true
|
||||
@@ -28,7 +27,7 @@ jobs:
|
||||
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@v9
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
operations-per-run: 999
|
||||
days-before-issue-stale: 180
|
||||
|
||||
44
.github/workflows/update-translations.sh
vendored
44
.github/workflows/update-translations.sh
vendored
@@ -1,44 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
I18N_DIR=resources/i18n
|
||||
|
||||
# Function to process JSON: remove empty attributes and sort
|
||||
process_json() {
|
||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||
}
|
||||
|
||||
check_lang_diff() {
|
||||
filename=${I18N_DIR}/"$1".json
|
||||
url=$(curl -s -X POST https://poeditor.com/api/ \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d action="export" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$1" \
|
||||
-d type="key_value_json" | jq -r .item)
|
||||
if [ -z "$url" ]; then
|
||||
echo "Failed to export $1"
|
||||
return 1
|
||||
fi
|
||||
curl -sSL "$url" > poeditor.json
|
||||
|
||||
process_json "$filename" > "$filename".tmp
|
||||
process_json poeditor.json > poeditor.tmp
|
||||
|
||||
diff=$(diff -u "$filename".tmp poeditor.tmp) || true
|
||||
if [ -n "$diff" ]; then
|
||||
echo "$diff"
|
||||
mv poeditor.json "$filename"
|
||||
fi
|
||||
|
||||
rm -f poeditor.json poeditor.tmp "$filename".tmp
|
||||
}
|
||||
|
||||
for file in ${I18N_DIR}/*.json; do
|
||||
name=$(basename "$file")
|
||||
code=$(echo "$name" | cut -f1 -d.)
|
||||
lang=$(jq -r .languageName < "$file")
|
||||
echo "Downloading $lang ($code)"
|
||||
check_lang_diff "$code"
|
||||
done
|
||||
8
.github/workflows/update-translations.yml
vendored
8
.github/workflows/update-translations.yml
vendored
@@ -8,21 +8,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: Get updated translations
|
||||
env:
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
run: |
|
||||
.github/workflows/update-translations.sh
|
||||
./update-translations.sh
|
||||
- name: Show changes, if any
|
||||
run: |
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: Update translations
|
||||
title: "fix(ui): update translations from POEditor"
|
||||
title: Update translations from POEditor
|
||||
branch: update-translations
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,4 +25,4 @@ tags
|
||||
.gitinfo
|
||||
docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
test-123.db
|
||||
/api/openapi.yaml
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
run:
|
||||
go: "1.19"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exportloopref
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
@@ -25,13 +28,8 @@ linters:
|
||||
- unused
|
||||
- whitespace
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149
|
||||
issues:
|
||||
exclude-rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: "(G501|G401|G505):"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# GoReleaser config
|
||||
project_name: navidrome
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- id: navidrome_linux_amd64
|
||||
@@ -117,56 +116,24 @@ archives:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
replacements:
|
||||
darwin: macOS
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
|
||||
checksum:
|
||||
name_template: "{{ .ProjectName }}_checksums.txt"
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ .Tag }}-SNAPSHOT"
|
||||
name_template: "{{ .Tag }}-SNAPSHOT"
|
||||
|
||||
release:
|
||||
draft: true
|
||||
mode: append
|
||||
footer: |
|
||||
**Full Changelog**: https://github.com/navidrome/navidrome/compare/{{ .PreviousTag }}...{{ .Tag }}
|
||||
|
||||
## Helping out
|
||||
|
||||
This release is only possible thanks to the support of some **awesome people**!
|
||||
|
||||
Want to be one of them?
|
||||
You can [sponsor](https://github.com/sponsors/deluan), pay me a [Ko-fi](https://ko-fi.com/deluan) or [contribute with code](https://www.navidrome.org/docs/developers/).
|
||||
|
||||
## Where to go next?
|
||||
|
||||
* Read installation instructions on our [website](https://www.navidrome.org/docs/installation/).
|
||||
* Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)!
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
use: github
|
||||
# sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^test:"
|
||||
- Merge pull request
|
||||
- Merge remote-tracking branch
|
||||
- Merge branch
|
||||
- go mod tidy
|
||||
groups:
|
||||
- title: "New Features"
|
||||
regexp: '^.*?feat(\(.+\))??!?:.+$'
|
||||
order: 100
|
||||
- title: "Security updates"
|
||||
regexp: '^.*?sec(\(.+\))??!?:.+$'
|
||||
order: 150
|
||||
- title: "Bug fixes"
|
||||
regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
|
||||
order: 200
|
||||
- title: "Documentation updates"
|
||||
regexp: ^.*?docs?(\(.+\))??!?:.+$
|
||||
order: 400
|
||||
- title: "Build process updates"
|
||||
regexp: ^.*?(build|ci)(\(.+\))??!?:.+$
|
||||
order: 400
|
||||
- title: Other work
|
||||
order: 9999
|
||||
- "^docs:"
|
||||
|
||||
@@ -48,15 +48,14 @@ This improves the readability of the messages
|
||||
It can be one of the following:
|
||||
1. **feat**: Addition of a new feature
|
||||
2. **fix**: Bug fix
|
||||
3. **sec**: Fixing security issues
|
||||
4. **docs**: Documentation Changes
|
||||
5. **style**: Changes to styling
|
||||
6. **refactor**: Refactoring of code
|
||||
7. **perf**: Code that affects performance
|
||||
8. **test**: Updating or improving the current tests
|
||||
9. **build**: Changes to Build process
|
||||
10. **revert**: Reverting to a previous commit
|
||||
11. **chore** : updating grunt tasks etc
|
||||
3. **docs**: Documentation Changes
|
||||
4. **style**: Changes to styling
|
||||
5. **refactor**: Refactoring of code
|
||||
6. **perf**: Code that affects performance
|
||||
7. **test**: Updating or improving the current tests
|
||||
8. **build**: Changes to Build process
|
||||
9. **revert**: Reverting to a previous commit
|
||||
10. **chore** : updating grunt tasks etc
|
||||
|
||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||
|
||||
|
||||
76
Makefile
76
Makefile
@@ -9,9 +9,7 @@ GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION ?= 1.23.0-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
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...
|
||||
@@ -22,7 +20,7 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||
server: check_go_env ##@Development Start the backend in development mode
|
||||
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
@@ -34,7 +32,7 @@ test: ##@Development Run Go tests
|
||||
go test -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: test ##@Development Run Go and JS tests
|
||||
testall: test ##@Development Run Go and JS tests, and validate OpenAPI spec
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
.PHONY: testall
|
||||
|
||||
@@ -42,33 +40,40 @@ lint: ##@Development Lint Go code
|
||||
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
|
||||
lintapi: api/openapi.yaml ##@Development Lint OpenAPI spec
|
||||
npx @redocly/cli lint api/openapi.yaml
|
||||
.PHONY: lintapi
|
||||
|
||||
lintall: lint lintapi ##@Development Lint Go and JS code
|
||||
@(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
|
||||
|
||||
format: ##@Development Format code
|
||||
@(cd ./ui && npm run prettier)
|
||||
@go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v _gen.go$$`
|
||||
@go mod tidy
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
gen: check_go_env api ##@Development Update Generated Code (wire, mocks, openapi, etc)
|
||||
go run github.com/google/wire/cmd/wire@latest ./...
|
||||
.PHONY: wire
|
||||
|
||||
api: check_go_env api/openapi.yaml
|
||||
go generate ./server/api/...
|
||||
.PHONY: api
|
||||
|
||||
spec_parts=$(shell find api -name '*.yml')
|
||||
api/openapi.yaml: $(spec_parts)
|
||||
@echo "Bundling OpenAPI spec..."
|
||||
npx @redocly/cli bundle api/spec.yml -o api/openapi.yaml
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo@latest ./server/subsonic/...
|
||||
.PHONY: snapshots
|
||||
|
||||
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/migrations create ${name} sql
|
||||
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/migrations create ${name}
|
||||
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name}
|
||||
.PHONY: migration
|
||||
|
||||
setup-dev: setup
|
||||
@@ -80,46 +85,37 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
@(cd .git/hooks && ln -sf ../../git/* .)
|
||||
.PHONY: setup-git
|
||||
|
||||
build: check_go_env buildjs ##@Build Build the project
|
||||
buildall: buildjs build ##@Build Build the project, both frontend and backend
|
||||
.PHONY: buildall
|
||||
|
||||
build: warning-noui-build check_go_env ##@Build Build only backend
|
||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: build
|
||||
|
||||
buildall: deprecated build
|
||||
.PHONY: buildall
|
||||
|
||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
.PHONY: debug-build
|
||||
|
||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||
buildjs: check_node_env ##@Build Build only frontend
|
||||
@(cd ./ui && npm run build)
|
||||
.PHONY: buildjs
|
||||
|
||||
ui/build/index.html: $(UI_SRC_FILES)
|
||||
@(cd ./ui && npm run build)
|
||||
|
||||
all: buildjs ##@Cross_Compilation Build binaries for all supported platforms.
|
||||
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
|
||||
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser release --clean --skip=publish --snapshot
|
||||
goreleaser release --rm-dist --skip-publish --snapshot
|
||||
.PHONY: all
|
||||
|
||||
single: buildjs ##@Cross_Compilation Build binaries for a single supported platforms.
|
||||
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
|
||||
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
|
||||
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
|
||||
echo "Options:"; \
|
||||
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Building binaries for ${GOOS}/${GOARCH} using builder ${CI_RELEASER_VERSION}"
|
||||
@echo "Building binaries for ${GOOS}/${GOARCH}"
|
||||
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser build --clean --snapshot -p 2 --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
.PHONY: single
|
||||
|
||||
docker: buildjs ##@Build Build Docker linux/amd64 image (tagged as `deluan/navidrome:develop`)
|
||||
GOOS=linux GOARCH=amd64 make single
|
||||
@echo "Building Docker image"
|
||||
docker build . --platform linux/amd64 -t deluan/navidrome:develop -f .github/workflows/pipeline.dockerfile
|
||||
.PHONY: docker
|
||||
warning-noui-build:
|
||||
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
|
||||
.PHONY: warning-noui-build
|
||||
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
@@ -175,10 +171,6 @@ check_node_env:
|
||||
pre-push: lintall testall
|
||||
.PHONY: pre-push
|
||||
|
||||
deprecated:
|
||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||
.PHONY: deprecated
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
HELP_FUN = \
|
||||
|
||||
28
api/parameters/_index.yml
Normal file
28
api/parameters/_index.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
pageOffset:
|
||||
$ref: './query/pageOffset.yml'
|
||||
pageLimit:
|
||||
$ref: './query/pageLimit.yml'
|
||||
filterEquals:
|
||||
$ref: './query/filterEquals.yml'
|
||||
filterLessThan:
|
||||
$ref: './query/filterLessThan.yml'
|
||||
filterLessOrEqual:
|
||||
$ref: './query/filterLessOrEqual.yml'
|
||||
filterGreaterThan:
|
||||
$ref: './query/filterGreaterThan.yml'
|
||||
filterGreaterOrEqual:
|
||||
$ref: './query/filterGreaterOrEqual.yml'
|
||||
filterContains:
|
||||
$ref: './query/filterContains.yml'
|
||||
filterStartsWith:
|
||||
$ref: './query/filterStartsWith.yml'
|
||||
filterEndsWith:
|
||||
$ref: './query/filterEndsWith.yml'
|
||||
sort:
|
||||
$ref: './query/sort.yml'
|
||||
include:
|
||||
$ref: './query/include.yml'
|
||||
includeForTracks:
|
||||
$ref: './query/includeForTracks.yml'
|
||||
includeForAlbums:
|
||||
$ref: './query/includeForAlbums.yml'
|
||||
9
api/parameters/query/filterContains.yml
Normal file
9
api/parameters/query/filterContains.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[contains]
|
||||
in: query
|
||||
description: 'Filter by any property containing text. Usage: filter[contains]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\w+'
|
||||
9
api/parameters/query/filterEndsWith.yml
Normal file
9
api/parameters/query/filterEndsWith.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[endsWith]
|
||||
in: query
|
||||
description: 'Filter by any property that ends with text. Usage: filter[endsWith]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\w+'
|
||||
9
api/parameters/query/filterEquals.yml
Normal file
9
api/parameters/query/filterEquals.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[equals]
|
||||
in: query
|
||||
description: 'Filter by any property with an exact match. Usage: filter[equals]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\w+'
|
||||
9
api/parameters/query/filterGreaterOrEqual.yml
Normal file
9
api/parameters/query/filterGreaterOrEqual.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[greaterOrEqual]
|
||||
in: query
|
||||
description: 'Filter by any numeric property greater than or equal to a value. Usage: filter[greaterOrEqual]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\d+'
|
||||
9
api/parameters/query/filterGreaterThan.yml
Normal file
9
api/parameters/query/filterGreaterThan.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[greaterThan]
|
||||
in: query
|
||||
description: 'Filter by any numeric property greater than a value. Usage: filter[greaterThan]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\d+'
|
||||
9
api/parameters/query/filterLessOrEqual.yml
Normal file
9
api/parameters/query/filterLessOrEqual.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[lessOrEqual]
|
||||
in: query
|
||||
description: 'Filter by any numeric property less than or equal to a value. Usage: filter[lessOrEqual]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\d+'
|
||||
9
api/parameters/query/filterLessThan.yml
Normal file
9
api/parameters/query/filterLessThan.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[lessThan]
|
||||
in: query
|
||||
description: 'Filter by any numeric property less than a value. Usage: filter[lessThan]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\d+'
|
||||
9
api/parameters/query/filterStartsWith.yml
Normal file
9
api/parameters/query/filterStartsWith.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: filter[startsWith]
|
||||
in: query
|
||||
description: 'Filter by any property that starts with text. Usage: filter[startsWith]=property:value'
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: '^\w+:\w+'
|
||||
6
api/parameters/query/include.yml
Normal file
6
api/parameters/query/include.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: include
|
||||
in: query
|
||||
description: Related resources to include in the response, separated by commas
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
13
api/parameters/query/includeForAlbum.yml
Normal file
13
api/parameters/query/includeForAlbum.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
name: include
|
||||
in: query
|
||||
description: Related resources to include in the response, separated by commas
|
||||
required: false
|
||||
explode: false
|
||||
schema:
|
||||
type: array
|
||||
x-go-type: includeSlice
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- track
|
||||
- artist
|
||||
12
api/parameters/query/includeForAlbums.yml
Normal file
12
api/parameters/query/includeForAlbums.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
name: include
|
||||
in: query
|
||||
description: Related resources to include in the response, separated by commas
|
||||
required: false
|
||||
explode: false
|
||||
schema:
|
||||
type: array
|
||||
x-go-type: includeSlice
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- artist
|
||||
13
api/parameters/query/includeForTracks.yml
Normal file
13
api/parameters/query/includeForTracks.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
name: include
|
||||
in: query
|
||||
description: Related resources to include in the response, separated by commas
|
||||
required: false
|
||||
explode: false
|
||||
schema:
|
||||
type: array
|
||||
x-go-type: includeSlice
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- album
|
||||
- artist
|
||||
9
api/parameters/query/pageLimit.yml
Normal file
9
api/parameters/query/pageLimit.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: page[limit]
|
||||
in: query
|
||||
description: The number of items per page
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
default: 10
|
||||
9
api/parameters/query/pageOffset.yml
Normal file
9
api/parameters/query/pageOffset.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: page[offset]
|
||||
in: query
|
||||
description: The offset for pagination
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
default: 0
|
||||
6
api/parameters/query/sort.yml
Normal file
6
api/parameters/query/sort.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: sort
|
||||
in: query
|
||||
description: Sort the results by one or more properties, separated by commas. Prefix the property with '-' for descending order.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
33
api/resources/album.yml
Normal file
33
api/resources/album.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
get:
|
||||
summary: Retrieve an individual album
|
||||
operationId: getAlbum
|
||||
parameters:
|
||||
- $ref: '../parameters/query/includeForAlbum.yml'
|
||||
- name: albumId
|
||||
in: path
|
||||
description: The unique identifier of the album
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: An album object
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: '../schemas/Album.yml'
|
||||
included:
|
||||
description: Included resources, as requested by the `include` query parameter
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/IncludedResource.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'404':
|
||||
$ref: '../responses/NotFound.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
44
api/resources/albums.yml
Normal file
44
api/resources/albums.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
get:
|
||||
summary: Retrieve a list of albums
|
||||
operationId: getAlbums
|
||||
parameters:
|
||||
- $ref: '../parameters/query/pageLimit.yml'
|
||||
- $ref: '../parameters/query/pageOffset.yml'
|
||||
- $ref: '../parameters/query/filterEquals.yml'
|
||||
- $ref: '../parameters/query/filterContains.yml'
|
||||
- $ref: '../parameters/query/filterLessThan.yml'
|
||||
- $ref: '../parameters/query/filterLessOrEqual.yml'
|
||||
- $ref: '../parameters/query/filterGreaterThan.yml'
|
||||
- $ref: '../parameters/query/filterGreaterOrEqual.yml'
|
||||
- $ref: '../parameters/query/filterStartsWith.yml'
|
||||
- $ref: '../parameters/query/filterEndsWith.yml'
|
||||
- $ref: '../parameters/query/sort.yml'
|
||||
- $ref: '../parameters/query/includeForAlbums.yml'
|
||||
responses:
|
||||
'200':
|
||||
description: A list of albums
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, links]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/Album.yml'
|
||||
links:
|
||||
$ref: '../schemas/PaginationLinks.yml'
|
||||
meta:
|
||||
$ref: '../schemas/PaginationMeta.yml'
|
||||
included:
|
||||
description: Included resources, as requested by the `include` query parameter
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/IncludedResource.yml'
|
||||
'400':
|
||||
$ref: '../responses/BadRequest.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
28
api/resources/artist.yml
Normal file
28
api/resources/artist.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
get:
|
||||
summary: Retrieve an individual artist
|
||||
operationId: getArtist
|
||||
parameters:
|
||||
- $ref: '../parameters/query/include.yml'
|
||||
- name: artistId
|
||||
in: path
|
||||
description: The unique identifier of the artist
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: An artist object
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: '../schemas/Artist.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'404':
|
||||
$ref: '../responses/NotFound.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
39
api/resources/artists.yml
Normal file
39
api/resources/artists.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
get:
|
||||
summary: Retrieve a list of artists
|
||||
operationId: getArtists
|
||||
parameters:
|
||||
- $ref: '../parameters/query/pageLimit.yml'
|
||||
- $ref: '../parameters/query/pageOffset.yml'
|
||||
- $ref: '../parameters/query/filterEquals.yml'
|
||||
- $ref: '../parameters/query/filterContains.yml'
|
||||
- $ref: '../parameters/query/filterLessThan.yml'
|
||||
- $ref: '../parameters/query/filterLessOrEqual.yml'
|
||||
- $ref: '../parameters/query/filterGreaterThan.yml'
|
||||
- $ref: '../parameters/query/filterGreaterOrEqual.yml'
|
||||
- $ref: '../parameters/query/filterStartsWith.yml'
|
||||
- $ref: '../parameters/query/filterEndsWith.yml'
|
||||
- $ref: '../parameters/query/sort.yml'
|
||||
- $ref: '../parameters/query/include.yml'
|
||||
responses:
|
||||
'200':
|
||||
description: A list of artists
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, links]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/Artist.yml'
|
||||
links:
|
||||
$ref: '../schemas/PaginationLinks.yml'
|
||||
meta:
|
||||
$ref: '../schemas/PaginationMeta.yml'
|
||||
'400':
|
||||
$ref: '../responses/BadRequest.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
18
api/resources/server.yml
Normal file
18
api/resources/server.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
get:
|
||||
summary: Get server's global info
|
||||
operationId: getServerInfo
|
||||
responses:
|
||||
'200':
|
||||
description: The response’s data key maps to a resource object dictionary representing the server.
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: '../schemas/ServerInfo.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
33
api/resources/track.yml
Normal file
33
api/resources/track.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
get:
|
||||
summary: Retrieve an individual track
|
||||
operationId: getTrack
|
||||
parameters:
|
||||
- $ref: '../parameters/query/includeForTracks.yml'
|
||||
- name: trackId
|
||||
in: path
|
||||
description: The unique identifier of the track
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: A track object
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: '../schemas/Track.yml'
|
||||
included:
|
||||
description: Included resources, as requested by the `include` query parameter
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/IncludedResource.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'404':
|
||||
$ref: '../responses/NotFound.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
44
api/resources/tracks.yml
Normal file
44
api/resources/tracks.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
get:
|
||||
summary: Retrieve a list of tracks
|
||||
operationId: getTracks
|
||||
parameters:
|
||||
- $ref: '../parameters/query/pageLimit.yml'
|
||||
- $ref: '../parameters/query/pageOffset.yml'
|
||||
- $ref: '../parameters/query/filterEquals.yml'
|
||||
- $ref: '../parameters/query/filterContains.yml'
|
||||
- $ref: '../parameters/query/filterLessThan.yml'
|
||||
- $ref: '../parameters/query/filterLessOrEqual.yml'
|
||||
- $ref: '../parameters/query/filterGreaterThan.yml'
|
||||
- $ref: '../parameters/query/filterGreaterOrEqual.yml'
|
||||
- $ref: '../parameters/query/filterStartsWith.yml'
|
||||
- $ref: '../parameters/query/filterEndsWith.yml'
|
||||
- $ref: '../parameters/query/sort.yml'
|
||||
- $ref: '../parameters/query/includeForTracks.yml'
|
||||
responses:
|
||||
'200':
|
||||
description: A list of tracks
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, links]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/Track.yml'
|
||||
links:
|
||||
$ref: '../schemas/PaginationLinks.yml'
|
||||
meta:
|
||||
$ref: '../schemas/PaginationMeta.yml'
|
||||
included:
|
||||
description: Included resources, as requested by the `include` query parameter
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/IncludedResource.yml'
|
||||
'400':
|
||||
$ref: '../responses/BadRequest.yml'
|
||||
'403':
|
||||
$ref: '../responses/NotAuthorized.yml'
|
||||
'500':
|
||||
$ref: '../responses/InternalServerError.yml'
|
||||
5
api/responses/BadRequest.yml
Normal file
5
api/responses/BadRequest.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
description: Bad Request
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '../schemas/ErrorList.yml'
|
||||
5
api/responses/InternalServerError.yml
Normal file
5
api/responses/InternalServerError.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '../schemas/ErrorList.yml'
|
||||
5
api/responses/NotAuthorized.yml
Normal file
5
api/responses/NotAuthorized.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
description: Not Authorized
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '../schemas/ErrorList.yml'
|
||||
5
api/responses/NotFound.yml
Normal file
5
api/responses/NotFound.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
description: Not Found
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '../schemas/ErrorList.yml'
|
||||
8
api/responses/_index.yml
Normal file
8
api/responses/_index.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
NotFound:
|
||||
$ref: './NotFound.yml'
|
||||
NotAuthorized:
|
||||
$ref: './NotAuthorized.yml'
|
||||
BadRequest:
|
||||
$ref: './BadRequest.yml'
|
||||
InternalServerError:
|
||||
$ref: './InternalServerError.yml'
|
||||
20
api/schemas/Album.yml
Normal file
20
api/schemas/Album.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
allOf:
|
||||
- $ref: './ResourceObject.yml'
|
||||
- type: object
|
||||
properties:
|
||||
attributes:
|
||||
$ref: './AlbumAttributes.yml'
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
artists:
|
||||
type: array
|
||||
items:
|
||||
$ref: './AlbumArtistRelationship.yml'
|
||||
tracks:
|
||||
type: array
|
||||
items:
|
||||
$ref: './AlbumTrackRelationship.yml'
|
||||
required:
|
||||
- artists
|
||||
- tracks
|
||||
9
api/schemas/AlbumArtistRelationship.yml
Normal file
9
api/schemas/AlbumArtistRelationship.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: './ArtistMetaObject.yml'
|
||||
data:
|
||||
$ref: './ResourceObject.yml'
|
||||
required:
|
||||
- meta
|
||||
- data
|
||||
23
api/schemas/AlbumAttributes.yml
Normal file
23
api/schemas/AlbumAttributes.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: The title of the album
|
||||
artist:
|
||||
type: string
|
||||
description: The artist of the album
|
||||
releaseDate:
|
||||
type: string
|
||||
description: The release date of the album
|
||||
tracktotal:
|
||||
type: integer
|
||||
description: The number of tracks on the album
|
||||
disctotal:
|
||||
type: integer
|
||||
description: The number of discs in the album
|
||||
genre:
|
||||
type: string
|
||||
description: The genre of the album
|
||||
required:
|
||||
- title
|
||||
- artist
|
||||
6
api/schemas/AlbumTrackRelationship.yml
Normal file
6
api/schemas/AlbumTrackRelationship.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: './ResourceObject.yml'
|
||||
required:
|
||||
- data
|
||||
27
api/schemas/Artist.yml
Normal file
27
api/schemas/Artist.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
allOf:
|
||||
- $ref: './ResourceObject.yml'
|
||||
- type: object
|
||||
properties:
|
||||
attributes:
|
||||
$ref: './ArtistAttributes.yml'
|
||||
relationships:
|
||||
type: object
|
||||
properties:
|
||||
tracks:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: './ArtistTrackRelationship.yml'
|
||||
required:
|
||||
- data
|
||||
albums:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: './ArtistAlbumRelationship.yml'
|
||||
required:
|
||||
- data
|
||||
9
api/schemas/ArtistAlbumRelationship.yml
Normal file
9
api/schemas/ArtistAlbumRelationship.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: './ArtistMetaObject.yml'
|
||||
data:
|
||||
$ref: './ResourceObject.yml'
|
||||
required:
|
||||
- meta
|
||||
- data
|
||||
10
api/schemas/ArtistAttributes.yml
Normal file
10
api/schemas/ArtistAttributes.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The name of the artist
|
||||
bio:
|
||||
type: string
|
||||
description: A short biography of the artist
|
||||
required:
|
||||
- name
|
||||
6
api/schemas/ArtistMetaObject.yml
Normal file
6
api/schemas/ArtistMetaObject.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
type: object
|
||||
properties:
|
||||
role:
|
||||
$ref: './ArtistRole.yml'
|
||||
required:
|
||||
- role
|
||||
5
api/schemas/ArtistRole.yml
Normal file
5
api/schemas/ArtistRole.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
type: string
|
||||
enum:
|
||||
- artist
|
||||
- albumArtist
|
||||
description: The role of an artist in a track or album
|
||||
14
api/schemas/ArtistTrackRelationship.yml
Normal file
14
api/schemas/ArtistTrackRelationship.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
role:
|
||||
$ref: './ArtistRole.yml'
|
||||
required:
|
||||
- role
|
||||
data:
|
||||
$ref: './ResourceObject.yml'
|
||||
required:
|
||||
- meta
|
||||
- data
|
||||
7
api/schemas/ErrorList.yml
Normal file
7
api/schemas/ErrorList.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
type: object
|
||||
required: [errors]
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: './ErrorObject.yml'
|
||||
10
api/schemas/ErrorObject.yml
Normal file
10
api/schemas/ErrorObject.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
10
api/schemas/IncludedResource.yml
Normal file
10
api/schemas/IncludedResource.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
oneOf:
|
||||
- $ref: './Track.yml'
|
||||
- $ref: './Album.yml'
|
||||
- $ref: './Artist.yml'
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
track: './Track.yml'
|
||||
album: './Album.yml'
|
||||
artist: './Artist.yml'
|
||||
14
api/schemas/PaginationLinks.yml
Normal file
14
api/schemas/PaginationLinks.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
type: object
|
||||
properties:
|
||||
first:
|
||||
type: string
|
||||
format: uri
|
||||
prev:
|
||||
type: string
|
||||
format: uri
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
last:
|
||||
type: string
|
||||
format: uri
|
||||
14
api/schemas/PaginationMeta.yml
Normal file
14
api/schemas/PaginationMeta.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
type: object
|
||||
properties:
|
||||
currentPage:
|
||||
type: integer
|
||||
format: int32
|
||||
description: The current page in the collection
|
||||
totalPages:
|
||||
type: integer
|
||||
format: int32
|
||||
description: The total number of pages in the collection
|
||||
totalItems:
|
||||
type: integer
|
||||
format: int32
|
||||
description: The total number of items in the collection
|
||||
18
api/schemas/ResourceList.yml
Normal file
18
api/schemas/ResourceList.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
oneOf:
|
||||
- $ref: './Track.yml'
|
||||
- $ref: './Album.yml'
|
||||
- $ref: './Artist.yml'
|
||||
- type: array
|
||||
items:
|
||||
$ref: './ResourceObject.yml'
|
||||
included:
|
||||
type: array
|
||||
items:
|
||||
$ref: './IncludedResource.yml'
|
||||
links:
|
||||
$ref: './PaginationLinks.yml'
|
||||
meta:
|
||||
$ref: './PaginationMeta.yml'
|
||||
8
api/schemas/ResourceObject.yml
Normal file
8
api/schemas/ResourceObject.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
type: object
|
||||
required: [id, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The unique identifier for the resource
|
||||
type:
|
||||
$ref: './ResourceType.yml'
|
||||
6
api/schemas/ResourceType.yml
Normal file
6
api/schemas/ResourceType.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
type: string
|
||||
description: The type of the resource
|
||||
enum:
|
||||
- album
|
||||
- artist
|
||||
- track
|
||||
24
api/schemas/ServerInfo.yml
Normal file
24
api/schemas/ServerInfo.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
type: object
|
||||
required: [server, serverVersion, authRequired, features]
|
||||
properties:
|
||||
server:
|
||||
type: string
|
||||
description: The name of the server software.
|
||||
example: "navidrome"
|
||||
serverVersion:
|
||||
type: string
|
||||
description: The version number of the server.
|
||||
example: "0.60.0"
|
||||
authRequired:
|
||||
type: boolean
|
||||
description: Whether the user has access to the server.
|
||||
example: true
|
||||
features:
|
||||
type: array
|
||||
description: A list of optional features the server supports.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- albums
|
||||
- artists
|
||||
- images
|
||||
8
api/schemas/Track.yml
Normal file
8
api/schemas/Track.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
allOf:
|
||||
- $ref: './ResourceObject.yml'
|
||||
- type: object
|
||||
properties:
|
||||
attributes:
|
||||
$ref: './TrackAttributes.yml'
|
||||
relationships:
|
||||
$ref: './TrackRelationships.yml'
|
||||
9
api/schemas/TrackArtistRelationship.yml
Normal file
9
api/schemas/TrackArtistRelationship.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: './ArtistMetaObject.yml'
|
||||
data:
|
||||
$ref: './ResourceObject.yml'
|
||||
required:
|
||||
- meta
|
||||
- data
|
||||
55
api/schemas/TrackAttributes.yml
Normal file
55
api/schemas/TrackAttributes.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
type: object
|
||||
required: [title, artist, album, albumartist, track, mimetype, duration, channels, bitrate, size]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: The title of the track
|
||||
artist: # TODO: Remove
|
||||
type: string
|
||||
description: The name of the artist who performed the track
|
||||
albumartist: # TODO: Remove
|
||||
type: string
|
||||
description: The primary artist of the album the track belongs to.
|
||||
album: # TODO Remove
|
||||
type: string
|
||||
description: The name of the album the track belongs to
|
||||
genre: # TODO Remove
|
||||
type: string
|
||||
description: The genre of the track.
|
||||
track:
|
||||
type: integer
|
||||
description: The track number within the album.
|
||||
disc:
|
||||
type: integer
|
||||
description: The disc number within a multi-disc album.
|
||||
year:
|
||||
type: integer
|
||||
description: The release year of the track or album.
|
||||
bpm:
|
||||
type: integer
|
||||
description: The beats per minute (BPM) of the track.
|
||||
recording-mbid:
|
||||
type: string
|
||||
description: The MusicBrainz identifier for the recording of the track.
|
||||
track-mbid:
|
||||
type: string
|
||||
description: The MusicBrainz identifier for the track.
|
||||
comments:
|
||||
type: string
|
||||
description: Any additional comments or notes about the track.
|
||||
mimetype:
|
||||
type: string
|
||||
description: The MIME type of the audio file.
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: The duration of the track in seconds
|
||||
channels:
|
||||
type: integer
|
||||
description: The number of audio channels in the track.
|
||||
bitrate:
|
||||
type: integer
|
||||
description: The bitrate of the audio file in kilobits per second (kbps).
|
||||
size:
|
||||
type: integer
|
||||
description: The size of the audio file in bytes.
|
||||
12
api/schemas/TrackRelationships.yml
Normal file
12
api/schemas/TrackRelationships.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
type: object
|
||||
properties:
|
||||
artists:
|
||||
type: array
|
||||
items:
|
||||
$ref: './TrackArtistRelationship.yml'
|
||||
albums:
|
||||
type: array
|
||||
items:
|
||||
$ref: './AlbumTrackRelationship.yml'
|
||||
required:
|
||||
- artists
|
||||
46
api/schemas/_index.yml
Normal file
46
api/schemas/_index.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
ServerInfo:
|
||||
$ref: './ServerInfo.yml'
|
||||
ResourceObject:
|
||||
$ref: './ResourceObject.yml'
|
||||
ResourceType:
|
||||
$ref: './ResourceType.yml'
|
||||
ResourceList:
|
||||
$ref: './ResourceList.yml'
|
||||
IncludedResource:
|
||||
$ref: './IncludedResource.yml'
|
||||
Track:
|
||||
$ref: './Track.yml'
|
||||
TrackAttributes:
|
||||
$ref: './TrackAttributes.yml'
|
||||
TrackRelationships:
|
||||
$ref: './TrackRelationships.yml'
|
||||
TrackArtistRelationship:
|
||||
$ref: './TrackArtistRelationship.yml'
|
||||
ArtistRole:
|
||||
$ref: './ArtistRole.yml'
|
||||
Artist:
|
||||
$ref: './Artist.yml'
|
||||
ArtistAttributes:
|
||||
$ref: './ArtistAttributes.yml'
|
||||
ArtistAlbumRelationship:
|
||||
$ref: './ArtistAlbumRelationship.yml'
|
||||
ArtistTrackRelationship:
|
||||
$ref: './ArtistTrackRelationship.yml'
|
||||
ArtistMetaObject:
|
||||
$ref: './ArtistMetaObject.yml'
|
||||
Album:
|
||||
$ref: './Album.yml'
|
||||
AlbumAttributes:
|
||||
$ref: './AlbumAttributes.yml'
|
||||
AlbumArtistRelationship:
|
||||
$ref: './AlbumArtistRelationship.yml'
|
||||
AlbumTrackRelationship:
|
||||
$ref: './AlbumTrackRelationship.yml'
|
||||
PaginationLinks:
|
||||
$ref: './PaginationLinks.yml'
|
||||
PaginationMeta:
|
||||
$ref: './PaginationMeta.yml'
|
||||
ErrorList:
|
||||
$ref: './ErrorList.yml'
|
||||
ErrorObject:
|
||||
$ref: './ErrorObject.yml'
|
||||
52
api/spec.yml
Normal file
52
api/spec.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
version: 0.2.0
|
||||
title: Navidrome API
|
||||
description: >
|
||||
This spec describes the Navidrome API, which allows users to browse and manage their music library via a JSON:API
|
||||
based interface. The API provides endpoints for albums, tracks, artists, playlists and images, along with their
|
||||
relationships. Clients can retrieve information about the items in the library, filter and sort results, and
|
||||
perform actions such as creating and deleting playlists. With this API, developers can build music apps and
|
||||
services that integrate with Navidrome music server, providing a seamless experience for users to access and
|
||||
manage their music collection.
|
||||
contact:
|
||||
name: Navidrome
|
||||
url: https://navidrome.org
|
||||
license:
|
||||
name: GNU General Public License v3.0
|
||||
url: https://github.com/navidrome/navidrome/blob/master/LICENSE
|
||||
|
||||
servers:
|
||||
- url: /api/v2
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
/server:
|
||||
$ref: './resources/server.yml'
|
||||
/tracks:
|
||||
$ref: './resources/tracks.yml'
|
||||
/tracks/{trackId}:
|
||||
$ref: './resources/track.yml'
|
||||
/artists:
|
||||
$ref: './resources/artists.yml'
|
||||
/artists/{artistId}:
|
||||
$ref: './resources/artist.yml'
|
||||
/albums:
|
||||
$ref: './resources/albums.yml'
|
||||
/albums/{albumId}:
|
||||
$ref: './resources/album.yml'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
$ref: './parameters/_index.yml'
|
||||
schemas:
|
||||
$ref: './schemas/_index.yml'
|
||||
responses:
|
||||
$ref: './responses/_index.yml'
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
@@ -1,99 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scanner/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
extractor string
|
||||
format string
|
||||
)
|
||||
|
||||
func init() {
|
||||
inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)")
|
||||
inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)")
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
}
|
||||
|
||||
var inspectCmd = &cobra.Command{
|
||||
Use: "inspect [files to inspect]",
|
||||
Short: "Inspect tags",
|
||||
Long: "Show file tags as seen by Navidrome",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runInspector(args)
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
out := v.([]inspectorOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File))
|
||||
t, _ := toml.Marshal(out[i].RawTags)
|
||||
res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t))
|
||||
t, _ = toml.Marshal(out[i].MappedTags)
|
||||
res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t))
|
||||
}
|
||||
return []byte(res.String()), nil
|
||||
}
|
||||
|
||||
type inspectorOutput struct {
|
||||
File string
|
||||
RawTags metadata.ParsedTags
|
||||
MappedTags model.MediaFile
|
||||
}
|
||||
|
||||
func runInspector(args []string) {
|
||||
if extractor != "" {
|
||||
conf.Server.Scanner.Extractor = extractor
|
||||
}
|
||||
log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor)
|
||||
md, err := metadata.Extract(args...)
|
||||
if err != nil {
|
||||
log.Fatal("Error extracting tags", err)
|
||||
}
|
||||
mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{})
|
||||
marshal := marshalers[format]
|
||||
if marshal == nil {
|
||||
log.Fatal("Invalid format", "format", format)
|
||||
}
|
||||
var out []inspectorOutput
|
||||
for k, v := range md {
|
||||
if !model.IsAudioFile(k) {
|
||||
continue
|
||||
}
|
||||
if len(v.Tags) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, inspectorOutput{
|
||||
File: k,
|
||||
RawTags: v.Tags,
|
||||
MappedTags: mapper.ToMediaFile(v),
|
||||
})
|
||||
}
|
||||
data, _ := marshal(out)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
75
cmd/root.go
75
cmd/root.go
@@ -2,11 +2,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -19,11 +18,14 @@ import (
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/server/backgrounds"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var interrupted = errors.New("service was interrupted")
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
noBanner bool
|
||||
@@ -39,14 +41,10 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome()
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
},
|
||||
Version: consts.Version,
|
||||
}
|
||||
)
|
||||
|
||||
// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function.
|
||||
func Execute() {
|
||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
@@ -62,42 +60,26 @@ func preRun() {
|
||||
conf.Load()
|
||||
}
|
||||
|
||||
func postRun() {
|
||||
log.Info("Navidrome stopped, bye.")
|
||||
}
|
||||
|
||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||
// it will cancel the context and exit gracefully.
|
||||
func runNavidrome() {
|
||||
defer db.Init()()
|
||||
db.Init()
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Error("Error closing DB", err)
|
||||
}
|
||||
log.Info("Navidrome stopped, bye.")
|
||||
}()
|
||||
|
||||
ctx, cancel := mainContext()
|
||||
defer cancel()
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startSignaler(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicScan(ctx))
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||
func mainContext() (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(context.Background(),
|
||||
os.Interrupt,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGABRT,
|
||||
)
|
||||
}
|
||||
|
||||
// startServer starts the Navidrome web server, adding all the necessary routers.
|
||||
func startServer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
a := CreateServer(conf.Server.MusicFolder)
|
||||
@@ -119,13 +101,13 @@ func startServer(ctx context.Context) func() error {
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
a.MountRouter("New Native API", consts.URLPathAPI, CreateNewNativeAPIRouter())
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
schedule := conf.Server.ScanSchedule
|
||||
@@ -155,30 +137,16 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks.
|
||||
func startScheduler(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting scheduler")
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
schedulerInstance.Run(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||
// It is responsible for the Jukebox functionality
|
||||
func startPlaybackServer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
if !conf.Server.Jukebox.Enabled {
|
||||
log.Debug("Jukebox is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Starting Jukebox service")
|
||||
playbackInstance := GetPlaybackServer()
|
||||
return playbackInstance.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement some struct tags to map flags to viper
|
||||
func init() {
|
||||
cobra.OnInitialize(func() {
|
||||
@@ -201,7 +169,6 @@ func init() {
|
||||
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("unixsocketperm", viper.GetString("unixsocketperm"), "optional file permission for the unix socket")
|
||||
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")
|
||||
@@ -210,7 +177,6 @@ func init() {
|
||||
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
|
||||
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
|
||||
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
|
||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||
@@ -219,7 +185,6 @@ 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("unixsocketperm", rootCmd.Flags().Lookup("unixsocketperm"))
|
||||
_ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey"))
|
||||
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
|
||||
|
||||
|
||||
27
cmd/signaler_nonunix.go
Normal file
27
cmd/signaler_nonunix.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build windows || plan9
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func startSignaler(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting signaler")
|
||||
|
||||
return func() error {
|
||||
var sigChan = make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
log.Info(ctx, "Received termination signal", "signal", sig)
|
||||
return interrupted
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,17 +14,28 @@ import (
|
||||
|
||||
const triggerScanSignal = syscall.SIGUSR1
|
||||
|
||||
func startSignaller(ctx context.Context) func() error {
|
||||
func startSignaler(ctx context.Context) func() error {
|
||||
log.Info(ctx, "Starting signaler")
|
||||
scanner := GetScanner()
|
||||
|
||||
return func() error {
|
||||
var sigChan = make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, triggerScanSignal)
|
||||
signal.Notify(
|
||||
sigChan,
|
||||
os.Interrupt,
|
||||
triggerScanSignal,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGABRT,
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
if sig != triggerScanSignal {
|
||||
log.Info(ctx, "Received termination signal", "signal", sig)
|
||||
return interrupted
|
||||
}
|
||||
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
|
||||
start := time.Now()
|
||||
err := scanner.RescanAll(ctx, false)
|
||||
@@ -1,14 +0,0 @@
|
||||
//go:build windows || plan9
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Windows and Plan9 don't support SIGUSR1, so we don't need to start a signaler
|
||||
func startSignaller(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:generate go run github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
@@ -14,40 +14,47 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/api"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Injectors from wire_injectors.go:
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
serverServer := server.New(dataStore, broker)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists)
|
||||
router := nativeapi.New(dataStore, share)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateNewNativeAPIRouter() *api.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := api.New(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
@@ -58,19 +65,17 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
scanner := GetScanner()
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
@@ -85,22 +90,22 @@ func CreatePublicRouter() *public.Router {
|
||||
}
|
||||
|
||||
func CreateLastFMRouter() *lastfm.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := lastfm.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := listenbrainz.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
func createScanner() scanner.Scanner {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -109,17 +114,23 @@ func GetScanner() scanner.Scanner {
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
|
||||
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
|
||||
return scannerScanner
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, db.Db)
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, api.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/api"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
@@ -22,20 +24,20 @@ import (
|
||||
var allProviders = wire.NewSet(
|
||||
core.Set,
|
||||
artwork.Set,
|
||||
server.New,
|
||||
subsonic.New,
|
||||
nativeapi.New,
|
||||
api.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.GetInstance,
|
||||
db.Db,
|
||||
)
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
panic(wire.Build(
|
||||
server.New,
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
@@ -46,9 +48,16 @@ func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateNewNativeAPIRouter() *api.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
GetScanner,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -70,14 +79,22 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
))
|
||||
}
|
||||
|
||||
// Scanner must be a Singleton
|
||||
var (
|
||||
onceScanner sync.Once
|
||||
scannerInstance scanner.Scanner
|
||||
)
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
onceScanner.Do(func() {
|
||||
scannerInstance = createScanner()
|
||||
})
|
||||
return scannerInstance
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
func createScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
scanner.New,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -12,81 +12,72 @@ import (
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
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
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
AutoImportPlaylists bool
|
||||
DefaultPlaylistPublicVisibility bool
|
||||
PlaylistsPath string
|
||||
SmartPlaylistRefreshDelay time.Duration
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
FFmpegPath string
|
||||
MPVPath string
|
||||
MPVCmdTemplate string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
ArtistArtPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
HTTPSecurityHeaders secureOptions
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
MusicFolder string
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
ScanInterval time.Duration
|
||||
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
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
RecentlyAddedByModTime bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
SubsonicArtistParticipations bool
|
||||
FFmpegPath string
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
ArtistArtPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
GATrackingID string
|
||||
EnableLogRedacting bool
|
||||
AuthRequestLimit int
|
||||
AuthWindowLength time.Duration
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -103,7 +94,6 @@ type configOptions struct {
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
@@ -134,24 +124,11 @@ type listenBrainzOptions struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type secureOptions struct {
|
||||
CustomFrameOptionsValue string
|
||||
}
|
||||
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
}
|
||||
|
||||
type AudioDeviceDefinition []string
|
||||
|
||||
type jukeboxOptions struct {
|
||||
Enabled bool
|
||||
Devices []AudioDeviceDefinition
|
||||
Default string
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -216,7 +193,7 @@ func Load() {
|
||||
}
|
||||
|
||||
// Print current configuration if log level is Debug
|
||||
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
if log.CurrentLevel() >= log.LevelDebug {
|
||||
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
|
||||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
@@ -286,7 +263,6 @@ func init() {
|
||||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("unixsocketperm", "0660")
|
||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||
viper.SetDefault("scaninterval", -1)
|
||||
viper.SetDefault("scanschedule", "@every 1m")
|
||||
@@ -299,26 +275,20 @@ func init() {
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
viper.SetDefault("enableartworkprecache", true)
|
||||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
||||
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
viper.SetDefault("enableMediaFileCoverArt", true)
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
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("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
@@ -343,11 +313,6 @@ func init() {
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", "/metrics")
|
||||
|
||||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
viper.SetDefault("scanner.genreseparators", ";/,")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
@@ -355,15 +320,13 @@ func init() {
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey)
|
||||
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
@@ -371,13 +334,11 @@ func init() {
|
||||
viper.SetDefault("devautologinusername", "")
|
||||
viper.SetDefault("devactivitypanel", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("devenablebufferedscrobble", true)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package mime
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type mimeConf struct {
|
||||
Types map[string]string `yaml:"types"`
|
||||
Lossless []string `yaml:"lossless"`
|
||||
}
|
||||
|
||||
var LosslessFormats []string
|
||||
|
||||
func initMimeTypes() {
|
||||
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
||||
_ = mime.AddExtensionType(".js", "text/javascript")
|
||||
_ = mime.AddExtensionType(".css", "text/css")
|
||||
|
||||
f, err := resources.FS().Open("mime_types.yaml")
|
||||
if err != nil {
|
||||
log.Fatal("Fatal error opening mime_types.yaml", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var mimeConf mimeConf
|
||||
err = yaml.NewDecoder(f).Decode(&mimeConf)
|
||||
if err != nil {
|
||||
log.Fatal("Fatal error parsing mime_types.yaml", err)
|
||||
}
|
||||
for ext, typ := range mimeConf.Types {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
|
||||
for _, ext := range mimeConf.Lossless {
|
||||
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(initMimeTypes)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_cache_size=1000000000&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on&_txlock=immediate"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathNativeAPI = "/api"
|
||||
URLPathAPI = "/api/v2"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
@@ -81,36 +82,32 @@ const (
|
||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Shared secrets (only add here "secrets" that can be public)
|
||||
const (
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
|
||||
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []struct {
|
||||
Name string
|
||||
TargetFormat string
|
||||
DefaultBitRate int
|
||||
Command string
|
||||
}{
|
||||
DefaultTranscodings = []map[string]interface{}{
|
||||
{
|
||||
Name: "mp3 audio",
|
||||
TargetFormat: "mp3",
|
||||
DefaultBitRate: 192,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
"name": "mp3 audio",
|
||||
"targetFormat": "mp3",
|
||||
"defaultBitRate": 192,
|
||||
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
Name: "opus audio",
|
||||
TargetFormat: "opus",
|
||||
DefaultBitRate: 128,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
"name": "opus audio",
|
||||
"targetFormat": "opus",
|
||||
"defaultBitRate": 128,
|
||||
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
{
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
"name": "aac audio",
|
||||
"targetFormat": "aac",
|
||||
"defaultBitRate": 256,
|
||||
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
64
consts/mime_types.go
Normal file
64
consts/mime_types.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package consts
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type format struct {
|
||||
typ string
|
||||
lossless bool
|
||||
}
|
||||
|
||||
var audioFormats = map[string]format{
|
||||
".mp3": {typ: "audio/mpeg"},
|
||||
".ogg": {typ: "audio/ogg"},
|
||||
".oga": {typ: "audio/ogg"},
|
||||
".opus": {typ: "audio/ogg"},
|
||||
".aac": {typ: "audio/mp4"},
|
||||
".alac": {typ: "audio/mp4", lossless: true},
|
||||
".m4a": {typ: "audio/mp4"},
|
||||
".m4b": {typ: "audio/mp4"},
|
||||
".flac": {typ: "audio/flac", lossless: true},
|
||||
".wav": {typ: "audio/x-wav", lossless: true},
|
||||
".wma": {typ: "audio/x-ms-wma"},
|
||||
".ape": {typ: "audio/x-monkeys-audio", lossless: true},
|
||||
".mpc": {typ: "audio/x-musepack"},
|
||||
".shn": {typ: "audio/x-shn", lossless: true},
|
||||
".aif": {typ: "audio/x-aiff"},
|
||||
".aiff": {typ: "audio/x-aiff"},
|
||||
".m3u": {typ: "audio/x-mpegurl"},
|
||||
".pls": {typ: "audio/x-scpls"},
|
||||
".dsf": {typ: "audio/dsd", lossless: true},
|
||||
".wv": {typ: "audio/x-wavpack", lossless: true},
|
||||
".wvp": {typ: "audio/x-wavpack", lossless: true},
|
||||
".mka": {typ: "audio/x-matroska"},
|
||||
}
|
||||
var imageFormats = map[string]string{
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".png": "image/png",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
var LosslessFormats []string
|
||||
|
||||
func init() {
|
||||
for ext, fmt := range audioFormats {
|
||||
_ = mime.AddExtensionType(ext, fmt.typ)
|
||||
if fmt.lossless {
|
||||
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
}
|
||||
sort.Strings(LosslessFormats)
|
||||
for ext, typ := range imageFormats {
|
||||
_ = mime.AddExtensionType(ext, typ)
|
||||
}
|
||||
|
||||
// In some circumstances, Windows sets JS mime-type to `text/plain`!
|
||||
_ = mime.AddExtensionType(".js", "text/javascript")
|
||||
_ = mime.AddExtensionType(".css", "text/css")
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
#
|
||||
# navidrome_enable (bool): Set to YES to enable navidrome
|
||||
# Default: NO
|
||||
# navidrome_config (str): navidrome configuration file
|
||||
# navidrome_config (str): navidrome configration file
|
||||
# Default: /usr/local/etc/navidrome/config.toml
|
||||
# navidrome_datafolder (str): navidrome Folder to store application data
|
||||
# Default: www
|
||||
|
||||
@@ -134,7 +134,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||
}
|
||||
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
} else {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
|
||||
|
||||
@@ -60,7 +60,7 @@ var _ = Describe("Agents", func() {
|
||||
Describe("GetArtistMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test"))
|
||||
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)
|
||||
@@ -78,7 +78,7 @@ var _ = Describe("Agents", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistMBID(ctx, "123", "test")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -91,7 +91,7 @@ var _ = Describe("Agents", func() {
|
||||
Describe("GetArtistURL", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
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, "")
|
||||
@@ -109,7 +109,7 @@ var _ = Describe("Agents", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -122,7 +122,7 @@ var _ = Describe("Agents", func() {
|
||||
Describe("GetArtistBiography", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
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, "")
|
||||
@@ -140,7 +140,7 @@ var _ = Describe("Agents", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -156,13 +156,13 @@ var _ = Describe("Agents", func() {
|
||||
URL: "imageUrl",
|
||||
Size: 100,
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError("not found"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -178,13 +178,13 @@ var _ = Describe("Agents", func() {
|
||||
Name: "Joe Dohn",
|
||||
MBID: "mbid321",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -200,13 +200,13 @@ var _ = Describe("Agents", func() {
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
@@ -236,13 +236,13 @@ var _ = Describe("Agents", func() {
|
||||
},
|
||||
},
|
||||
}))
|
||||
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
|
||||
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
|
||||
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -47,7 +47,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
||||
return l
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
mbid: track.MbzRecordingID,
|
||||
mbid: track.MbzTrackID,
|
||||
duration: int(track.Duration),
|
||||
albumArtist: track.AlbumArtist,
|
||||
})
|
||||
@@ -283,7 +283,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
mbid: s.MbzRecordingID,
|
||||
mbid: s.MbzTrackID,
|
||||
duration: int(s.Duration),
|
||||
albumArtist: s.AlbumArtist,
|
||||
timestamp: s.TimeStamp,
|
||||
@@ -311,14 +311,12 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.LastFM.Enabled {
|
||||
if conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
}
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,14 +234,14 @@ var _ = Describe("lastfmAgent", func() {
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzRecordingID: "mbz-123",
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzTrackID: "mbz-123",
|
||||
}
|
||||
})
|
||||
|
||||
@@ -262,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
@@ -289,7 +289,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
|
||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
//go:embed token_received.html
|
||||
@@ -89,14 +89,13 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
token, err := p.String("token")
|
||||
if err != nil {
|
||||
token := utils.ParamString(r, "token")
|
||||
if token == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid, err := p.String("uid")
|
||||
if err != nil {
|
||||
uid := utils.ParamString(r, "uid")
|
||||
if uid == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
@@ -104,7 +103,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
ctx := request.WithUser(r.Context(), model.User{ID: uid})
|
||||
err = s.fetchSessionKey(ctx, uid, token)
|
||||
err := s.fetchSessionKey(ctx, uid, token)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,7 +35,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
}
|
||||
@@ -55,9 +55,8 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
||||
SubmissionClientVersion: consts.Version,
|
||||
TrackNumber: track.TrackNumber,
|
||||
ArtistMbzIDs: []string{track.MbzArtistID},
|
||||
RecordingMbzID: track.MbzRecordingID,
|
||||
TrackMbzID: track.MbzTrackID,
|
||||
ReleaseMbID: track.MbzAlbumID,
|
||||
DurationMs: int(track.Duration * 1000),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,15 +32,14 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = newClient("http://localhost:8080", httpClient)
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
TrackNumber: 1,
|
||||
MbzRecordingID: "mbz-123",
|
||||
MbzAlbumID: "mbz-456",
|
||||
MbzArtistID: "mbz-789",
|
||||
Duration: 142.2,
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
TrackNumber: 1,
|
||||
MbzTrackID: "mbz-123",
|
||||
MbzAlbumID: "mbz-456",
|
||||
MbzArtistID: "mbz-789",
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,12 +60,11 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
"SubmissionClient": Equal(consts.AppName),
|
||||
"SubmissionClientVersion": Equal(consts.Version),
|
||||
"TrackNumber": Equal(track.TrackNumber),
|
||||
"RecordingMbzID": Equal(track.MbzRecordingID),
|
||||
"TrackMbzID": Equal(track.MbzTrackID),
|
||||
"ReleaseMbID": Equal(track.MbzAlbumID),
|
||||
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
|
||||
"mbz-789": Equal(track.MbzArtistID),
|
||||
}),
|
||||
"DurationMs": Equal(142200),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -76,10 +76,9 @@ type additionalInfo struct {
|
||||
SubmissionClient string `json:"submission_client,omitempty"`
|
||||
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
|
||||
TrackNumber int `json:"tracknumber,omitempty"`
|
||||
RecordingMbzID string `json:"recording_mbid,omitempty"`
|
||||
TrackMbzID string `json:"track_mbid,omitempty"`
|
||||
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
|
||||
ReleaseMbID string `json:"release_mbid,omitempty"`
|
||||
DurationMs int `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
|
||||
|
||||
@@ -74,11 +74,10 @@ var _ = Describe("client", func() {
|
||||
TrackName: "Track Title",
|
||||
ReleaseName: "Track Album",
|
||||
AdditionalInfo: additionalInfo{
|
||||
TrackNumber: 1,
|
||||
RecordingMbzID: "mbz-123",
|
||||
ArtistMbzIDs: []string{"mbz-789"},
|
||||
ReleaseMbID: "mbz-456",
|
||||
DurationMs: 142200,
|
||||
TrackNumber: 1,
|
||||
TrackMbzID: "mbz-123",
|
||||
ArtistMbzIDs: []string{"mbz-789"},
|
||||
ReleaseMbID: "mbz-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
|
||||
} else {
|
||||
r, err = os.Open(mf.Path)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
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)
|
||||
@@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
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)
|
||||
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
|
||||
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, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
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)
|
||||
@@ -192,8 +192,8 @@ type mockMediaStreamer struct {
|
||||
core.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (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 {
|
||||
@@ -41,10 +41,10 @@ type artworkReader interface {
|
||||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (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 {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size)
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
@@ -57,8 +57,8 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, squ
|
||||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size, square)
|
||||
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
|
||||
}
|
||||
@@ -107,11 +107,11 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
|
||||
return artID, nil
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int) (artworkReader, error) {
|
||||
var artReader artworkReader
|
||||
var err error
|
||||
if size > 0 || square {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
|
||||
if size > 0 {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size)
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
|
||||
@@ -4,11 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -215,83 +211,33 @@ var _ = Describe("Artwork", func() {
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
When("Square is false", 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(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
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(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
br, format, err := asImageReader(r)
|
||||
Expect(format).To(Equal("image/png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
img, _, err := image.Decode(br)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
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(), 200)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
br, format, err := asImageReader(r)
|
||||
Expect(format).To(Equal("image/jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", false, 200),
|
||||
Entry("landscape png image", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", false, 200),
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
)
|
||||
img, _, err := image.Decode(br)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ var _ = Describe("Artwork", func() {
|
||||
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, false)
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
@@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
type CacheWarmer interface {
|
||||
@@ -95,7 +94,7 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
batch := slices.Collect(maps.Keys(a.buffer))
|
||||
batch := maps.Keys(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
@@ -122,7 +121,7 @@ func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID)
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
log.Warn(ctx, "Error warming cache", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +129,9 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, false)
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
return fmt.Errorf("error cacheing id='%s': %w", id, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type artistReader struct {
|
||||
@@ -56,7 +56,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
||||
}
|
||||
}
|
||||
a.files = strings.Join(files, consts.Zwsp)
|
||||
a.artistFolder = str.LongestCommonPrefix(paths)
|
||||
a.artistFolder = utils.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(a.artistFolder, string(filepath.Separator)) {
|
||||
a.artistFolder, _ = filepath.Split(a.artistFolder)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
@@ -8,12 +9,14 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
)
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
@@ -21,18 +24,16 @@ type resizedArtworkReader struct {
|
||||
cacheKey string
|
||||
lastUpdate time.Time
|
||||
size int
|
||||
square bool
|
||||
a *artwork
|
||||
}
|
||||
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int) (*resizedArtworkReader, error) {
|
||||
r := &resizedArtworkReader{a: a}
|
||||
r.artID = artID
|
||||
r.size = size
|
||||
r.square = square
|
||||
|
||||
// Get lastUpdated and cacheKey from original artwork
|
||||
original, err := a.getArtworkReader(ctx, artID, 0, false)
|
||||
original, err := a.getArtworkReader(ctx, artID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,11 +43,12 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) Key() string {
|
||||
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
|
||||
if a.square {
|
||||
return baseKey + ".square"
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
|
||||
return fmt.Sprintf(
|
||||
"%s.%d.%d",
|
||||
a.cacheKey,
|
||||
a.size,
|
||||
conf.Server.CoverJpegQuality,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
@@ -55,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, 0, false)
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -65,7 +67,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
r := io.TeeReader(orig, buf)
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := resizeImage(r, a.size, a.square)
|
||||
resized, origSize, err := resizeImage(r, a.size)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||
} else {
|
||||
@@ -82,39 +84,54 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(reader)
|
||||
func asImageReader(r io.Reader) (io.Reader, string, error) {
|
||||
br := bufio.NewReader(r)
|
||||
buf, err := br.Peek(512)
|
||||
if err == io.EOF && len(buf) > 0 {
|
||||
// Check if there are enough bytes to detect type
|
||||
typ := http.DetectContentType(buf)
|
||||
if typ != "" {
|
||||
return br, typ, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return br, http.DetectContentType(buf), nil
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
|
||||
r, format, err := asImageReader(reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if originalSize <= size && !square {
|
||||
// Don't upscale the image
|
||||
bounds := img.Bounds()
|
||||
originalSize := number.Max(bounds.Max.X, bounds.Max.Y)
|
||||
if originalSize <= size {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
var resized image.Image
|
||||
if originalSize >= size {
|
||||
resized = imaging.Fit(original, size, size, imaging.Lanczos)
|
||||
var m *image.NRGBA
|
||||
// Preserve the aspect ratio of the image.
|
||||
if bounds.Max.X > bounds.Max.Y {
|
||||
m = imaging.Resize(img, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
if bounds.Max.Y < bounds.Max.X {
|
||||
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
if square {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
resized = imaging.OverlayCenter(bg, resized, 1)
|
||||
m = imaging.Resize(img, 0, size, imaging.Lanczos)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if format == "png" || square {
|
||||
err = png.Encode(buf, resized)
|
||||
buf.Reset()
|
||||
if format == "image/png" {
|
||||
err = png.Encode(buf, m)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
}
|
||||
return buf, originalSize, 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 `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
|
||||
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)
|
||||
@@ -124,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, 0, false)
|
||||
r, _, err := a.Get(ctx, id, 0)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@@ -24,10 +23,9 @@ var (
|
||||
func Init(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
|
||||
secret, err := ds.Property(context.TODO()).Get(consts.JWTSecretKey)
|
||||
if err != nil || secret == "" {
|
||||
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
|
||||
if err != nil {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
secret = uuid.NewString()
|
||||
}
|
||||
Secret = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", Secret, nil)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
@@ -17,9 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -43,8 +42,8 @@ type ExternalMetadata interface {
|
||||
type externalMetadata struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
artistQueue chan<- *auxArtist
|
||||
albumQueue chan<- *auxAlbum
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
@@ -59,8 +58,8 @@ type auxArtist struct {
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -75,7 +74,7 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum,
|
||||
switch v := entity.(type) {
|
||||
case *model.Album:
|
||||
album.Album = *v
|
||||
album.Name = str.Clear(v.Name)
|
||||
album.Name = clearName(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getAlbum(ctx, v.AlbumID)
|
||||
default:
|
||||
@@ -91,19 +90,17 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
|
||||
if album.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
|
||||
err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateAlbumInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
if time.Since(album.ExternalInfoUpdatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||
e.albumQueue.enqueue(*album)
|
||||
enqueueRefresh(e.albumQueue, album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
@@ -121,7 +118,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbu
|
||||
return err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalInfoUpdatedAt = time.Now()
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
@@ -166,7 +163,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
artist.Artist = *v
|
||||
artist.Name = str.Clear(v.Name)
|
||||
artist.Name = clearName(v.Name)
|
||||
case *model.MediaFile:
|
||||
return e.getArtist(ctx, v.ArtistID)
|
||||
case *model.Album:
|
||||
@@ -177,6 +174,17 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
// Replace some Unicode chars with their equivalent ASCII
|
||||
func clearName(name string) string {
|
||||
name = strings.ReplaceAll(name, "–", "-")
|
||||
name = strings.ReplaceAll(name, "‐", "-")
|
||||
name = strings.ReplaceAll(name, "“", `"`)
|
||||
name = strings.ReplaceAll(name, "”", `"`)
|
||||
name = strings.ReplaceAll(name, "‘", `'`)
|
||||
name = strings.ReplaceAll(name, "’", `'`)
|
||||
return name
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -194,9 +202,8 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
|
||||
}
|
||||
|
||||
// If we don't have any info, retrieves it now
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
|
||||
if artist.ExternalInfoUpdatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
|
||||
err := e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -204,9 +211,9 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
|
||||
e.artistQueue.enqueue(*artist)
|
||||
if time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
|
||||
enqueueRefresh(e.artistQueue, artist)
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
@@ -235,7 +242,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
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,
|
||||
@@ -258,14 +265,14 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
weightedSongs := utils.NewWeightedRandomChooser()
|
||||
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := max(count, 20)
|
||||
topCount := number.Max(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
@@ -293,12 +300,12 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
|
||||
var similarSongs model.MediaFiles
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.Pick()
|
||||
s, err := weightedSongs.GetAndRemove()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s)
|
||||
similarSongs = append(similarSongs, s.(model.MediaFile))
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
@@ -392,7 +399,7 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
|
||||
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
if mbid != "" {
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"mbz_recording_id": mbid},
|
||||
Filters: squirrel.Eq{"mbz_track_id": mbid},
|
||||
})
|
||||
if err == nil && len(mfs) > 0 {
|
||||
return &mfs[0], nil
|
||||
@@ -405,9 +412,9 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
squirrel.Eq{"artist_id": artistID},
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
},
|
||||
squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
|
||||
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||
Sort: "starred desc, rating desc, year asc",
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil || len(mfs) == 0 {
|
||||
@@ -425,11 +432,11 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bio = str.SanitizeText(bio)
|
||||
bio = utils.SanitizeText(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
@@ -505,7 +512,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
|
||||
}
|
||||
artist := &auxArtist{
|
||||
Artist: artists[0],
|
||||
Name: str.Clear(artists[0].Name),
|
||||
Name: clearName(artists[0].Name),
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
@@ -552,17 +559,15 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
|
||||
return nil
|
||||
}
|
||||
|
||||
type refreshQueue[T any] chan<- T
|
||||
|
||||
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, *T) error) refreshQueue[T] {
|
||||
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 item := <-queue:
|
||||
_ = processFn(ctx, &item)
|
||||
case a := <-queue:
|
||||
_ = processFn(ctx, a)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
@@ -573,9 +578,9 @@ func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context,
|
||||
return queue
|
||||
}
|
||||
|
||||
func (q *refreshQueue[T]) enqueue(item T) {
|
||||
func enqueueRefresh[T any](queue chan<- T, item T) {
|
||||
select {
|
||||
case *q <- item:
|
||||
default: // It is ok to miss a refresh request
|
||||
case queue <- item:
|
||||
default: // It is ok to miss a refresh
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user