Compare commits

..

4 Commits

Author SHA1 Message Date
Deluan
af4b2bb4c9 go mod tidy 2023-05-13 21:30:05 -04:00
Deluan
4773adba00 Add aliases for playlists and service commands 2023-05-13 21:28:13 -04:00
Deluan
7bbf4cbaea Fix lint errors 2023-05-13 21:28:13 -04:00
Deluan
cff19445ba Add service management 2023-05-13 21:28:11 -04:00
795 changed files with 43737 additions and 35372 deletions

View File

@@ -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

View File

@@ -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.19",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v20"
"NODE_VERSION": "v16"
}
},
"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,

View File

@@ -1,18 +1,10 @@
.DS_Store
ui/node_modules
ui/build
!ui/build/.gitkeep
Dockerfile
docker-compose*.yml
data
*.db
testDB
navidrome
navidrome.db
navidrome.toml
tmp
!tmp/taglib
dist
binaries
cache
music
!Dockerfile

View File

@@ -1,23 +0,0 @@
name: 'Download TagLib'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
version:
description: 'Version of TagLib to download'
required: true
platform:
description: 'Platform to download TagLib for'
default: 'linux-amd64'
runs:
using: 'composite'
steps:
- name: Download TagLib
shell: bash
run: |
mkdir -p /tmp/taglib
cd /tmp
FILE=taglib-${{ inputs.platform }}.tar.gz
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
tar -xzf ${FILE} -C taglib
PKG_CONFIG_PREFIX=/tmp/taglib
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV

View File

@@ -1,84 +0,0 @@
name: 'Prepare Docker Buildx environment'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
github_token:
description: 'GitHub token'
required: true
default: ''
hub_repository:
description: 'Docker Hub repository to push images to'
required: false
default: ''
hub_username:
description: 'Docker Hub username'
required: false
default: ''
hub_password:
description: 'Docker Hub password'
required: false
default: ''
outputs:
tags:
description: 'Docker image tags'
value: ${{ steps.meta.outputs.tags }}
labels:
description: 'Docker image labels'
value: ${{ steps.meta.outputs.labels }}
annotations:
description: 'Docker image annotations'
value: ${{ steps.meta.outputs.annotations }}
version:
description: 'Docker image version'
value: ${{ steps.meta.outputs.version }}
hub_repository:
description: 'Docker Hub repository'
value: ${{ env.DOCKER_HUB_REPO }}
hub_enabled:
description: 'Is Docker Hub enabled'
value: ${{ env.DOCKER_HUB_ENABLED }}
runs:
using: 'composite'
steps:
- name: Check Docker Hub configuration
shell: bash
run: |
if [ -z "${{inputs.hub_repository}}" ]; then
echo "DOCKER_HUB_REPO=none" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=false" >> $GITHUB_ENV
else
echo "DOCKER_HUB_REPO=${{inputs.hub_repository}}" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=true" >> $GITHUB_ENV
fi
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v3
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github_token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
with:
labels: |
maintainer=deluan@navidrome.org
images: |
name=${{env.DOCKER_HUB_REPO}},enable=${{env.DOCKER_HUB_ENABLED}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}

View File

@@ -10,13 +10,3 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/.github/workflows"
schedule:
interval: weekly
open-pull-requests-limit: 10

38
.github/workflows/pipeline.dockerfile vendored Normal file
View File

@@ -0,0 +1,38 @@
#####################################################
### Copy platform specific binary
FROM bash as copy-binary
ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64_v1/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/386" ]; then cp navidrome_linux_386_linux_386/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
RUN chmod +x /navidrome
#####################################################
### Build Final Image
FROM alpine as release
LABEL maintainer="deluan@navidrome.org"
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
COPY --from=copy-binary /navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_PORT 4533
ENV GODEBUG "asyncpreemptoff=1"
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

View File

@@ -1,4 +1,4 @@
name: "Pipeline: Test, Lint, Build"
name: 'Pipeline: Test, Lint, Build'
on:
push:
branches:
@@ -8,118 +8,81 @@ on:
pull_request:
branches:
- master
concurrency:
group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }}
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.0.2-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
git-version:
name: Get version info
runs-on: ubuntu-latest
outputs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Show git version info
run: |
echo "git describe (dirty): $(git describe --dirty --always --tags)"
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
echo "github_ref: $GITHUB_REF"
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
git tag -l
- name: Determine git current SHA and latest tag
id: git-version
run: |
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
if [ -n "$GIT_TAG" ]; then
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
GIT_TAG=${GIT_TAG}-SNAPSHOT
fi
echo "GIT_TAG=$GIT_TAG" >> $GITHUB_OUTPUT
fi
GIT_SHA=$(git rev-parse --short HEAD)
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
if [[ $PR_NUM != "null" ]]; then
GIT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-8)
GIT_SHA="pr-${PR_NUM}/${GIT_SHA}"
fi
echo "GIT_SHA=$GIT_SHA" >> $GITHUB_OUTPUT
echo "GIT_TAG=$GIT_TAG"
echo "GIT_SHA=$GIT_SHA"
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Download TagLib
uses: ./.github/actions/download-taglib
- name: Set up Go 1.20
uses: actions/setup-go@v3
with:
version: ${{ env.CROSS_TAGLIB_VERSION }}
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: Run go goimports
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'`
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
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
name: Test with Go ${{ matrix.go_version }}
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.20.x,1.19.x]
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Download TagLib
uses: ./.github/actions/download-taglib
- 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:
version: ${{ env.CROSS_TAGLIB_VERSION }}
go-version: ${{ matrix.go_version }}
cache: true
- name: Download dependencies
if: steps.cache-go.outputs.cache-hit != 'true'
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go mod download
- name: Test
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race -cover ./... -v
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go test -shuffle=on -race -cover ./... -v
js:
name: Test JS code
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: 16
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: npm install dependencies
run: |
@@ -141,289 +104,121 @@ jobs:
cd ui
npm run build
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
check-push-enabled:
name: Check Docker configuration
runs-on: ubuntu-latest
outputs:
is_enabled: ${{ steps.check.outputs.is_enabled }}
steps:
- name: Check if Docker push is configured
id: check
run: echo "is_enabled=${{ secrets.DOCKER_HUB_USERNAME != '' }}" >> $GITHUB_OUTPUT
build:
name: Build
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }}
IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }}
DOCKER_BUILD_SUMMARY: false
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
steps:
- name: Sanitize platform name
id: set-platform
run: |
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v4
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
- uses: actions/upload-artifact@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
outputs: |
type=local,dest=./output/${{ env.PLATFORM }}
target: binary
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v4
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
name: js-bundle
path: ui/build
retention-days: 7
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker.outputs.labels }}
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push-image.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
push-manifest:
name: Push Docker manifest
binaries:
name: Build binaries
needs: [js, go, go-lint]
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Create manifest list and push to Docker Hub
working-directory: /tmp/digests
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
- name: Inspect image in Docker Hub
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done
msi:
name: Build Windows installers
needs: [build, git-version]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: ./binaries
pattern: navidrome-windows*
merge-multiple: true
- name: Install Wix
run: sudo apt-get install -y wixl jq
- name: Build MSI
env:
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
run: |
rm -rf binaries/msi
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} 386
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} amd64
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v4
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
retention-days: 7
release:
name: Package/Release
needs: [build, msi]
runs-on: ubuntu-latest
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v4
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v3
with:
path: ./binaries
pattern: navidrome-*
merge-multiple: true
name: js-bundle
path: ui/build
- run: ls -lR ./binaries
- name: Set RELEASE_FLAGS for snapshot releases
if: env.IS_RELEASE == 'false'
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
- name: Config /github/workspace folder as trusted
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove build artifacts
run: |
ls -l ./dist
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
with:
name: packages
path: dist/navidrome_0*
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
- id: set-package-list
name: Export list of generated packages
run: |
cd dist
set +x
ITEMS=$(ls navidrome_0* | sed 's/^navidrome_0[^_]*_linux_//' | jq -R -s -c 'split("\n")[:-1]')
echo $ITEMS
echo "package_list=${ITEMS}" >> $GITHUB_OUTPUT
- 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
upload-packages:
name: Upload Linux PKG
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
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@v3
with:
name: binaries
path: |
dist
!dist/*.tar.gz
!dist/*.zip
retention-days: 7
docker:
name: Build and publish Docker images
needs: [binaries]
runs-on: ubuntu-latest
needs: [release]
strategy:
matrix:
item: ${{ fromJson(needs.release.outputs.package_list) }}
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v4
with:
name: packages
path: ./dist
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
if: env.DOCKER_IMAGE != ''
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
if: env.DOCKER_IMAGE != ''
# delete-artifacts:
# name: Delete unused artifacts
# runs-on: ubuntu-latest
# needs: [upload-packages]
# steps:
# - name: Delete all-packages artifact
# env:
# GH_TOKEN: ${{ github.token }}
# run: |
# for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("packages")) | .id'); do
# gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
# done
- uses: actions/checkout@v3
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v3
if: env.DOCKER_IMAGE != ''
with:
name: binaries
path: dist
- name: Login to Docker Hub
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
if: env.DOCKER_IMAGE != ''
id: meta
uses: docker/metadata-action@v4
with:
labels: |
maintainer=deluan
images: |
name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}
- name: Build and Push
if: env.DOCKER_IMAGE != ''
uses: docker/build-push-action@v4
with:
context: .
file: .github/workflows/pipeline.dockerfile
platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -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

View File

@@ -1,53 +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
# List changed languages to stderr
languages=""
for file in $(git diff --name-only --exit-code | grep json); do
lang=$(jq -r .languageName < "$file")
languages="${languages}$(echo $lang | tr -d '\n'), "
done
echo "${languages%??}" 1>&2

View File

@@ -6,28 +6,22 @@ on:
jobs:
update-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Get updated translations
id: poeditor
env:
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
run: |
.github/workflows/update-translations.sh 2> title.tmp
title=$(cat title.tmp)
echo "::set-output name=title::$title"
rm title.tmp
./update-translations.sh
- name: Show changes, if any
run: |
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot <navidrome-bot@navidrome.org>"
commit-message: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
title: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
commit-message: Update translations
title: Update translations from POEditor
branch: update-translations

12
.gitignore vendored
View File

@@ -11,17 +11,17 @@ wiki
TODO.md
var
navidrome.toml
!release/linux/navidrome.toml
master.zip
testDB
navidrome.db
cache/*
*.swp
embedded_gen.go
dist
music
*.db*
navidrome.db-shm
navidrome.db-wal
tags
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
binaries
taglib
navidrome-master
!contrib/docker-compose.yml

View File

@@ -1,6 +1,5 @@
run:
build-tags:
- netgo
go: "1.19"
linters:
enable:
@@ -8,11 +7,12 @@ linters:
- asciicheck
- bidichk
- bodyclose
- copyloopvar
- depguard
- dogsled
- durationcheck
- errcheck
- errorlint
- exportloopref
- gocyclo
- goprintffuncname
- gosec
@@ -29,13 +29,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):"

139
.goreleaser.yml Normal file
View File

@@ -0,0 +1,139 @@
# GoReleaser config
project_name: navidrome
builds:
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static -lz'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_386
env:
- CGO_ENABLED=1
- PKG_CONFIG_PATH=/i386/lib/pkgconfig
goos:
- linux
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
- CXX=arm-linux-gnueabi-g++
- PKG_CONFIG_PATH=/arm/lib/pkgconfig
goos:
- linux
goarch:
- arm
goarm:
- "5"
- "6"
- "7"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
- PKG_CONFIG_PATH=/arm64/lib/pkgconfig
goos:
- linux
goarch:
- arm64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_386
env:
- CGO_ENABLED=1
- CC=i686-w64-mingw32-gcc
- CXX=i686-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw32/lib/pkgconfig
goos:
- windows
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
goos:
- windows
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_darwin_amd64
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
- PKG_CONFIG_PATH=/darwin/lib/pkgconfig
goos:
- darwin
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
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:
name_template: "{{ .Tag }}-SNAPSHOT"
release:
draft: true
changelog:
# sort: asc
filters:
exclude:
- "^docs:"

2
.nvmrc
View File

@@ -1 +1 @@
v20
v16

View File

@@ -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

View File

@@ -1,144 +0,0 @@
FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross
########################################################################################################################
### Build xx (orignal image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS xx-build
# v1.5.0
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
cd xx && \
git checkout ${XX_VERSION} && \
mkdir -p /out && \
cp src/xx-* /out/
RUN cd /out && \
ln -s xx-cc /out/xx-clang && \
ln -s xx-cc /out/xx-clang++ && \
ln -s xx-cc /out/xx-c++ && \
ln -s xx-apt /out/xx-apt-get
# xx mimics the original tonistiigi/xx image
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.0.2-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
RUN <<EOT
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
FILE=taglib-${PLATFORM}.tar.gz
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
wget ${DOWNLOAD_URL}
mkdir /taglib
tar -xzf ${FILE} -C /taglib
EOT
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
COPY ui/package.json ui/package-lock.json ./
COPY ui/bin/ ./bin/
RUN npm ci
# Build bundle
COPY ui/ ./
RUN npm run build -- --outDir=/build
FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.23-bookworm AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
FROM --platform=$BUILDPLATFORM base AS build
# Install build dependencies for the target platform
ARG TARGETPLATFORM
ARG GIT_SHA
ARG GIT_TAG
RUN xx-apt install -y binutils gcc g++ libc6-dev zlib1g-dev
RUN xx-verify --setup
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV)
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
# So let's use gcc for everything except Darwin.
if [ "$(xx-info os)" != "darwin" ]; then
export CC=$(xx-info)-gcc
export CXX=$(xx-info)-g++
export LD_EXTRA="-extldflags '-static -latomic'"
fi
if [ "$(xx-info os)" = "windows" ]; then
export EXT=".exe"
fi
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .
EOT
# Verify if the binary was built for the correct platform and it is statically linked
RUN xx-verify --static /out/navidrome*
FROM scratch AS binary
COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.21 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install ffmpeg and mpv
RUN apk add -U --no-cache ffmpeg mpv
# Copy navidrome binary
COPY --from=build /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_PORT=4533
ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

132
Makefile
View File

@@ -3,21 +3,13 @@ NODE_VERSION=$(shell cat .nvmrc)
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
else
GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
endif
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.0.2-1
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...
@@ -25,23 +17,23 @@ setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and
.PHONY: setup
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env buildjs ##@Development Start the backend in development mode
@ND_ENABLEINSIGHTSCOLLECTOR="false" go run github.com/cespare/reflex@latest -d none -c reflex.conf
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
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -tags netgo -notify ./...
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./...
.PHONY: watch
test: ##@Development Run Go tests
go test -tags netgo -race -shuffle=on ./...
go test -race -shuffle=on ./...
.PHONY: test
testall: test ##@Development Run Go and JS tests
@(cd ./ui && npm run test:ci)
@(cd ./ui && npm test -- --watchAll=false)
.PHONY: testall
lint: ##@Development Lint Go code
@@ -53,14 +45,8 @@ lintall: lint ##@Development Lint Go and JS code
@(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
go run github.com/google/wire/cmd/wire@latest gen -tags=netgo ./...
go run github.com/google/wire/cmd/wire@latest ./...
.PHONY: wire
snapshots: ##@Development Update (GoLang) Snapshot tests
@@ -69,12 +55,12 @@ snapshots: ##@Development Update (GoLang) Snapshot tests
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
@@ -86,76 +72,45 @@ 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
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: build
buildall: deprecated build
buildall: buildjs build ##@Build Build the project, both frontend and backend
.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)" -tags=netgo
.PHONY: debug-build
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
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
docker-buildjs: ##@Build Build only frontend using Docker
docker build --output "./ui" --target ui-bundle .
.PHONY: docker-buildjs
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 --rm-dist --skip-publish --snapshot
.PHONY: all
ui/build/index.html: $(UI_SRC_FILES)
@(cd ./ui && npm run build)
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}"
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
.PHONY: single
docker-platforms: ##@Cross_Compilation List supported platforms
@echo "Supported platforms:"
@echo "$(SUPPORTED_PLATFORMS)" | tr ',' '\n' | sort | sed 's/^/ /'
@echo "\nUsage: make PLATFORMS=\"linux/amd64\" docker-build"
@echo " make IMAGE_PLATFORMS=\"linux/amd64\" docker-image"
.PHONY: docker-platforms
docker-build: ##@Cross_Compilation Cross-compile for any supported platform (check `make docker-platforms`)
docker buildx build \
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
docker buildx build \
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
docker-msi: ##@Cross_Compilation Build MSI installer for Windows
make docker-build PLATFORMS=windows/386,windows/amd64
DOCKER_CLI_HINTS=false docker build -q -t navidrome-msi-builder -f release/wix/msitools.dockerfile .
@rm -rf binaries/msi
docker run -it --rm -v $(PWD):/workspace -v $(PWD)/binaries:/workspace/binaries -e GIT_TAG=${GIT_TAG} \
navidrome-msi-builder sh -c "release/wix/build_msi.sh /workspace 386 && release/wix/build_msi.sh /workspace amd64"
@du -h binaries/msi/*.msi
.PHONY: docker-msi
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
.PHONY: package
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
( cd music; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music
@@ -164,11 +119,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
##########################################
#### Miscellaneous
clean:
@rm -rf ./binaries ./dist ./ui/build/*
@touch ./ui/build/.gitkeep
.PHONY: clean
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
@@ -208,10 +158,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 = \

View File

@@ -9,7 +9,6 @@
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Navidrome%20Guru-006BFF?style=flat-square)](https://gurubase.io/g/navidrome)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device. It's like your personal Spotify!
@@ -57,15 +56,6 @@ A share of the revenue helps fund the development of Navidrome at no additional
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
- Translated to **various languages**
## Translations
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
<a href="https://poeditor.com/">
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
</a>
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:

View File

@@ -1,186 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
backupCount int
backupDir string
force bool
restorePath string
)
func init() {
rootCmd.AddCommand(backupRoot)
backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
backupRoot.AddCommand(backupCmd)
pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
}
var (
backupRoot = &cobra.Command{
Use: "backup",
Aliases: []string{"bkp"},
Short: "Create, restore and prune database backups",
Long: "Create, restore and prune database backups",
}
backupCmd = &cobra.Command{
Use: "create",
Short: "Create a backup database",
Long: "Manually backup Navidrome database. This will ignore BackupCount",
Run: func(cmd *cobra.Command, _ []string) {
runBackup(cmd.Context())
},
}
pruneCmd = &cobra.Command{
Use: "prune",
Short: "Prune database backups",
Long: "Manually prune database backups according to backup rules",
Run: func(cmd *cobra.Command, _ []string) {
runPrune(cmd.Context())
},
}
restoreCommand = &cobra.Command{
Use: "restore",
Short: "Restore Navidrome database",
Long: "Restore Navidrome database from a backup. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runRestore(cmd.Context())
},
}
)
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Backup complete", "elapsed", elapsed, "path", path)
}
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
if backupCount != -1 {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
return
}
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Restore complete", "elapsed", elapsed)
}

View File

@@ -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))
}

View File

@@ -2,12 +2,8 @@ package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/auth"
@@ -19,51 +15,26 @@ import (
)
var (
playlistID string
outputFile string
userID string
outputFormat string
playlistID string
outputFile string
)
type displayPlaylist struct {
Id string `json:"id"`
Name string `json:"name"`
OwnerName string `json:"ownerName"`
OwnerId string `json:"ownerId"`
Public bool `json:"public"`
}
type displayPlaylists []displayPlaylist
func init() {
plsCmd.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
plsCmd.Flags().StringVarP(&outputFile, "output", "o", "", "output file (default stdout)")
_ = plsCmd.MarkFlagRequired("playlist")
rootCmd.AddCommand(plsCmd)
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
}
var (
plsCmd = &cobra.Command{
Use: "pls",
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
},
}
listCommand = &cobra.Command{
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
runList()
},
}
)
var plsCmd = &cobra.Command{
Use: "playlists",
Aliases: []string{"pls", "playlist"},
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
},
}
func runExporter() {
sqlDB := db.Db()
@@ -99,58 +70,3 @@ func runExporter() {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
func runList() {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := ds.User(ctx).FindByUsername(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving user by name", "name", userID, err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(userID)
if err != nil {
log.Fatal("Error retrieving user by id", "id", userID, err)
}
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
playlists, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
for _, playlist := range playlists {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
display := make(displayPlaylists, len(playlists))
for idx, playlist := range playlists {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
display[idx].OwnerName = playlist.OwnerName
display[idx].Public = playlist.Public
}
j, _ := json.Marshal(display)
fmt.Printf("%s\n", j)
}
}

View File

@@ -2,25 +2,30 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
"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
@@ -34,20 +39,17 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun()
},
Run: func(cmd *cobra.Command, args []string) {
runNavidrome(cmd.Context())
},
PostRun: func(cmd *cobra.Command, args []string) {
postRun()
runNavidrome(context.Background())
},
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 {
log.Fatal(err)
fmt.Println(err)
os.Exit(1)
}
}
@@ -58,44 +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(ctx context.Context) {
defer db.Init()()
ctx, cancel := mainContext(ctx)
defer cancel()
db.Init()
defer func() {
if err := db.Close(); err != nil {
log.Error("Error closing DB", err)
}
log.Info("Navidrome stopped, bye.")
}()
g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx))
g.Go(startSignaller(ctx))
g.Go(startSignaler(ctx))
g.Go(startScheduler(ctx))
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicScan(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(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(ctx context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(ctx,
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)
@@ -109,22 +93,20 @@ func startServer(ctx context.Context) func() error {
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
}
if conf.Server.Prometheus.Enabled {
p := CreatePrometheus()
// blocking call because takes <100ms but useful if fails
p.WriteInitialMetrics(ctx)
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
// blocking call because takes <1ms but useful if fails
core.WriteInitialMetrics()
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
}
if conf.Server.DevEnableProfiler {
a.MountRouter("Profiling", "/debug", middleware.Profiler())
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
}
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
@@ -154,84 +136,16 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
func schedulePeriodicBackup(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.Backup.Schedule
if schedule == "" {
log.Warn("Periodic backup is DISABLED")
return nil
}
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() {
start := time.Now()
path, err := db.Backup(ctx)
elapsed := time.Since(start)
if err != nil {
log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
return
}
log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
count, err := db.Prune(ctx)
if err != nil {
log.Error(ctx, "Error pruning database", "error", err)
} else if count > 0 {
log.Info(ctx, "Successfully pruned old files", "count", count)
} else {
log.Info(ctx, "No backups pruned")
}
})
return err
}
}
// 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
}
}
// startInsightsCollector starts the Navidrome Insight Collector, if configured.
func startInsightsCollector(ctx context.Context) func() error {
return func() error {
if !conf.Server.EnableInsightsCollector {
log.Info(ctx, "Insight Collector is DISABLED")
return nil
}
log.Info(ctx, "Starting Insight Collector")
select {
case <-time.After(conf.Server.DevInsightsInitialDelay):
case <-ctx.Done():
return nil
}
ic := CreateInsights()
ic.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() {
@@ -241,22 +155,17 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`)
rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored")
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB), needs write access")
rootCmd.PersistentFlags().String("cachefolder", viper.GetString("cachefolder"), "folder to store cache data (transcoding, images...), needs write access")
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB, cache...), needs write access")
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
rootCmd.PersistentFlags().String("logfile", viper.GetString("logfile"), "log file path, if not set logs will be printed to stderr")
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
_ = viper.BindPFlag("cachefolder", rootCmd.PersistentFlags().Lookup("cachefolder"))
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
_ = viper.BindPFlag("logfile", rootCmd.PersistentFlags().Lookup("logfile"))
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)")
rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)")
rootCmd.Flags().String("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")
@@ -265,7 +174,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`")
@@ -274,7 +182,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
View 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
}
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"sync"
"time"
"github.com/kardianos/service"
"github.com/navidrome/navidrome/conf"
@@ -20,9 +19,6 @@ var (
service.StatusStopped: "Stopped",
service.StatusRunning: "Running",
}
installUser string
workingDirectory string
)
func init() {
@@ -31,7 +27,6 @@ func init() {
svcCmd.AddCommand(buildStartCmd())
svcCmd.AddCommand(buildStopCmd())
svcCmd.AddCommand(buildStatusCmd())
svcCmd.AddCommand(buildExecuteCmd())
rootCmd.AddCommand(svcCmd)
}
@@ -46,78 +41,64 @@ var svcCmd = &cobra.Command{
type svcControl struct {
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
func (p *svcControl) Start(service.Service) error {
p.done = make(chan struct{})
func (p *svcControl) Start(_ service.Service) error {
p.ctx, p.cancel = context.WithCancel(context.Background())
go func() {
runNavidrome(p.ctx)
close(p.done)
}()
go p.run()
return nil
}
func (p *svcControl) Stop(service.Service) error {
func (p *svcControl) run() {
runNavidrome(p.ctx)
}
func (p *svcControl) Stop(_ service.Service) error {
log.Info("Stopping service")
p.cancel()
select {
case <-p.done:
log.Info("Service stopped gracefully")
case <-time.After(10 * time.Second):
log.Error("Service did not stop in time. Killing it.")
}
return nil
}
var svcInstance = sync.OnceValue(func() service.Service {
options := make(service.KeyValue)
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
options["LogDirectory"] = conf.Server.DataFolder
}
svcConfig := &service.Config{
UserName: installUser,
Name: "navidrome",
DisplayName: "Navidrome",
Description: "Your Personal Streaming Service",
Dependencies: []string{
"After=remote-fs.target network.target",
},
WorkingDirectory: executablePath(),
Option: options,
}
arguments := []string{"service", "execute"}
if conf.Server.ConfigFile != "" {
arguments = append(arguments, "-c", conf.Server.ConfigFile)
}
svcConfig.Arguments = arguments
var (
svc service.Service
svcOnce = sync.Once{}
)
prg := &svcControl{}
svc, err := service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
func svcInstance() service.Service {
svcOnce.Do(func() {
options := make(service.KeyValue)
options["Restart"] = "on-success"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = true
options["LogDirectory"] = conf.Server.DataFolder
svcConfig := &service.Config{
Name: "Navidrome",
DisplayName: "Navidrome",
Description: "Navidrome is a self-hosted music server and streamer",
Dependencies: []string{
"Requires=network.target",
"After=network-online.target syslog.target"},
WorkingDirectory: executablePath(),
Option: options,
}
if conf.Server.ConfigFile != "" {
svcConfig.Arguments = []string{"-c", conf.Server.ConfigFile}
}
prg := &svcControl{}
var err error
svc, err = service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
})
return svc
})
}
func runServiceCmd(cmd *cobra.Command, _ []string) {
_ = cmd.Help()
}
func executablePath() string {
if workingDirectory != "" {
return workingDirectory
}
ex, err := os.Executable()
if err != nil {
log.Fatal(err)
@@ -132,11 +113,6 @@ func buildInstallCmd() *cobra.Command {
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
println(" data folder: " + conf.Server.DataFolder)
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
println(" logs folder: " + conf.Server.DataFolder)
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
if err != nil {
@@ -151,15 +127,11 @@ func buildInstallCmd() *cobra.Command {
println("Service installed. Use 'navidrome svc start' to start it.")
}
cmd := &cobra.Command{
return &cobra.Command{
Use: "install",
Short: "Install Navidrome service.",
Run: runInstallCmd,
}
cmd.Flags().StringVarP(&installUser, "user", "u", "", "user to run service")
cmd.Flags().StringVarP(&workingDirectory, "working-directory", "w", "", "working directory of service")
return cmd
}
func buildUninstallCmd() *cobra.Command {
@@ -217,51 +189,3 @@ func buildStatusCmd() *cobra.Command {
},
}
}
func buildExecuteCmd() *cobra.Command {
return &cobra.Command{
Use: "execute",
Short: "Run navidrome as a service in the foreground (it is very unlikely you want to run this, you are better off running just navidrome)",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Run()
if err != nil {
log.Fatal(err)
}
},
}
}
const systemdScript = `[Unit]
Description={{.Description}}
ConditionFileIsExecutable={{.Path|cmdEscape}}
{{range $i, $dep := .Dependencies}}
{{$dep}} {{end}}
[Service]
StartLimitInterval=5
StartLimitBurst=10
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
{{if .UserName}}User={{.UserName}}{{end}}
{{if .Restart}}Restart={{.Restart}}{{end}}
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{.Name}}
DevicePolicy=closed
NoNewPrivileges=yes
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
ProtectSystem=full
[Install]
WantedBy=multi-user.target
`

View File

@@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
@@ -14,8 +14,6 @@ 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/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
@@ -25,6 +23,7 @@ import (
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
"sync"
)
// Injectors from wire_injectors.go:
@@ -33,8 +32,7 @@ func CreateServer(musicFolder string) *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
insights := metrics.GetInstance(dataStore)
serverServer := server.New(dataStore, broker, insights)
serverServer := server.New(dataStore, broker)
return serverServer
}
@@ -42,9 +40,7 @@ func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights)
router := nativeapi.New(dataStore, share)
return router
}
@@ -61,14 +57,11 @@ 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()
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker, metricsMetrics)
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
}
@@ -102,21 +95,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
insights := metrics.GetInstance(dataStore)
return insights
}
func CreatePrometheus() metrics.Metrics {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
return metricsMetrics
}
func GetScanner() scanner.Scanner {
func createScanner() scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playlists := core.NewPlaylists(dataStore)
@@ -127,18 +106,23 @@ func GetScanner() scanner.Scanner {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker, metricsMetrics)
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
return scannerScanner
}
func GetPlaybackServer() playback.PlaybackServer {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
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, metrics.NewPrometheusInstance)
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.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
}

View File

@@ -3,13 +3,13 @@
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/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
@@ -23,7 +23,6 @@ import (
var allProviders = wire.NewSet(
core.Set,
artwork.Set,
server.New,
subsonic.New,
nativeapi.New,
public.New,
@@ -31,13 +30,12 @@ var allProviders = wire.NewSet(
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.GetInstance,
db.Db,
metrics.NewPrometheusInstance,
)
func CreateServer(musicFolder string) *server.Server {
panic(wire.Build(
server.New,
allProviders,
))
}
@@ -51,6 +49,7 @@ func CreateNativeAPIRouter() *nativeapi.Router {
func CreateSubsonicAPIRouter() *subsonic.Router {
panic(wire.Build(
allProviders,
GetScanner,
))
}
@@ -72,26 +71,22 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,
))
}
func CreatePrometheus() metrics.Metrics {
panic(wire.Build(
allProviders,
))
}
// 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,
))
}

View File

@@ -1,4 +0,0 @@
package buildtags
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
// required build tags are disabled.

View File

@@ -1,11 +0,0 @@
//go:build netgo
package buildtags
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
// file requires it.
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
var NETGO = true

View File

@@ -12,84 +12,71 @@ 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
LogFile 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
EnableInsightsCollector 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
Backup backupOptions
ConfigFile string
Address string
Port int
MusicFolder string
DataFolder 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,24 +90,19 @@ type configOptions struct {
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool
DevShowArtistPage bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
}
type scannerOptions struct {
Extractor string
GenreSeparators string
GroupAlbumReleases bool
Extractor string
GenreSeparators string
}
type lastfmOptions struct {
@@ -140,29 +122,9 @@ type listenBrainzOptions struct {
BaseURL string
}
type secureOptions struct {
CustomFrameOptionsValue string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
}
type AudioDeviceDefinition []string
type jukeboxOptions struct {
Enabled bool
Devices []AudioDeviceDefinition
Default string
AdminOnly bool
}
type backupOptions struct {
Count int
Path string
Schedule string
}
var (
@@ -172,61 +134,25 @@ var (
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
Load()
}
func Load() {
parseIniFileConfiguration()
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err)
os.Exit(1)
}
if Server.CacheFolder == "" {
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
out := os.Stderr
if Server.LogFile != "" {
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
log.SetOutput(out)
}
log.SetLevelString(Server.LogLevel)
log.SetLogLevels(Server.DevLogLevels)
log.SetLogSourceLine(Server.DevLogSourceLine)
@@ -236,14 +162,10 @@ func Load() {
os.Exit(1)
}
if err := validateBackupSchedule(); err != nil {
os.Exit(1)
}
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error())
os.Exit(1)
}
Server.BasePath = u.Path
@@ -254,12 +176,12 @@ 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)
}
_, _ = fmt.Fprintln(out, prettyConf)
_, _ = fmt.Fprintln(os.Stderr, prettyConf)
}
if !Server.EnableExternalServices {
@@ -272,34 +194,8 @@ func Load() {
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]interface{}
err := viper.Unmarshal(&iniConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.ListenBrainz.Enabled = false
@@ -327,35 +223,15 @@ func validateScanSchedule() error {
Server.ScanSchedule = ""
return nil
}
var err error
Server.ScanSchedule, err = validateSchedule(Server.ScanSchedule, "ScanSchedule")
return err
}
func validateBackupSchedule() error {
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
var err error
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "BackupSchedule")
return err
}
func validateSchedule(schedule, field string) (string, error) {
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
if _, err := time.ParseDuration(Server.ScanSchedule); err == nil {
Server.ScanSchedule = "@every " + Server.ScanSchedule
}
c := cron.New()
id, err := c.AddFunc(schedule, func() {})
_, err := c.AddFunc(Server.ScanSchedule, func() {})
if err != nil {
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", field, err)
} else {
c.Remove(id)
log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err)
}
return schedule, err
return err
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -365,13 +241,10 @@ func AddHook(hook func()) {
func init() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
viper.SetDefault("loglevel", "info")
viper.SetDefault("logfile", "")
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")
@@ -384,26 +257,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")
@@ -417,7 +284,6 @@ func init() {
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
viper.SetDefault("enablelogredacting", true)
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
@@ -427,55 +293,37 @@ func init() {
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("jukebox.adminonly", true)
viper.SetDefault("prometheus.metricspath", "/metrics")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.groupalbumreleases", false)
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")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "")
viper.SetDefault("devautologinusername", "")
viper.SetDefault("devactivitypanel", true)
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
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)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
}
func InitConfig(cfgFile string) {

View File

@@ -1,48 +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")
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
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)
}

View File

@@ -3,7 +3,6 @@ package consts
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -58,7 +57,7 @@ const (
SkipScanFile = ".ndignore"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAlbumArt = "placeholder.png"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
@@ -70,61 +69,44 @@ const (
Zwsp = string('\u200b')
)
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
PrometheusAuthUser = "navidrome"
)
// Cache options
const (
TranscodingCacheDir = "transcoding"
TranscodingCacheDir = "cache/transcoding"
DefaultTranscodingCacheMaxItems = 0 // Unlimited
ImageCacheDir = "images"
ImageCacheDir = "cache/images"
DefaultImageCacheMaxItems = 0 // Unlimited
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Shared secrets (only add here "secrets" that can be public)
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
)
const (
InsightsIDKey = "InsightsID"
InsightsEndpoint = "https://insights.navidrome.org/collect"
InsightsUpdateInterval = 24 * time.Hour
InsightsInitialDelay = 30 * time.Minute
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: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: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:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
}
@@ -141,11 +123,3 @@ var (
ServerStart = time.Now()
)
var InContainer = func() bool {
// Check if the /.nddockerenv file exists
if _, err := os.Stat("/.nddockerenv"); err == nil {
return true
}
return false
}()

64
consts/mime_types.go Normal file
View 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")
}

View File

@@ -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

View File

@@ -11,13 +11,15 @@ WantedBy=multi-user.target
User=navidrome
Group=navidrome
Type=simple
ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml"
ExecStart=/usr/bin/navidrome
StateDirectory=navidrome
WorkingDirectory=/var/lib/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
EnvironmentFile=-/etc/sysconfig/navidrome
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
CapabilityBoundingSet=
DevicePolicy=closed

View File

@@ -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))

View File

@@ -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()

View File

@@ -3,21 +3,18 @@ package lastfm
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/andybalholm/cascadia"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"golang.org/x/net/html"
"github.com/navidrome/navidrome/utils"
)
const (
@@ -50,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
}
@@ -181,51 +178,13 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callArtistGetInfo(ctx, name, mbid)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
req, err := http.NewRequest(http.MethodGet, a.URL, nil)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
resp, err := l.client.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return nil, fmt.Errorf("parse html: %w", err)
}
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
return res, nil
}
for _, attr := range n.Attr {
if attr.Key == "content" {
res = []agents.ExternalImage{
{URL: attr.Val},
}
break
}
}
return res, nil
}
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
log.Warn(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "")
}
@@ -246,7 +205,7 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid s
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && a.Name == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Debug(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
log.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
return l.callArtistGetInfo(ctx, name, "")
}
@@ -262,7 +221,7 @@ func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbi
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && s.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Debug(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
log.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
return l.callArtistGetSimilar(ctx, name, "", limit)
}
if err != nil {
@@ -277,7 +236,7 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mb
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && t.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Debug(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
log.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
return l.callArtistGetTopTracks(ctx, artistName, "", count)
}
if err != nil {
@@ -298,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,
})
@@ -324,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,
@@ -352,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)
})
}
})
}

View File

@@ -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)))
})

View File

@@ -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
@@ -65,9 +65,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"apiKey": s.apiKey,
}
resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -91,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
}
@@ -106,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)

View File

@@ -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 (

View File

@@ -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),
},
},
}

View File

@@ -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),
}),
}),
}))

View File

@@ -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) {

View File

@@ -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",
},
},
}

View File

@@ -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
}

View File

@@ -86,7 +86,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultDisc b
if isMultDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
return fmt.Sprintf("%s/%s", mf.Album, file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@@ -130,11 +130,8 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
file := fmt.Sprintf("%02d - %s - %s.%s", idx+1, mf.Artist, mf.Title, ext)
return file
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
@@ -150,7 +147,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 +157,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)
}
}()

View File

@@ -33,8 +33,8 @@ var _ = Describe("Archiver", func() {
Context("ZipAlbum", func() {
It("zips an album correctly", func() {
mfs := model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
}
mfRepo := &mockMediaFileRepository{}
@@ -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(2)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -54,8 +54,8 @@ var _ = Describe("Archiver", func() {
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
})
})
@@ -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)
@@ -123,7 +123,7 @@ var _ = Describe("Archiver", func() {
Context("ZipPlaylist", func() {
It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 1", Title: "track1"}},
{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
}
@@ -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)
@@ -146,7 +146,7 @@ var _ = Describe("Archiver", func() {
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
})
})
@@ -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)
}

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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))
})
})

View File

@@ -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, true)
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)

View File

@@ -63,7 +63,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
ff = append(ff, fromTag(ctx, a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
ff = append(ff, fromTag(a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
case pattern == "external":
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
case a.album.ImageFiles != "":

View File

@@ -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)
}

View File

@@ -55,7 +55,7 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
var ff []sourceFunc
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
ff = []sourceFunc{
fromTag(ctx, a.mediafile.Path),
fromTag(a.mediafile.Path),
fromFFmpegTag(ctx, a.a.ffmpeg, a.mediafile.Path),
}
}

View File

@@ -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,13 +57,17 @@ 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
}
// Keep a copy of the original data. In case we can't resize it, send it as is
buf := new(bytes.Buffer)
r := io.TeeReader(orig, buf)
defer orig.Close()
resized, origSize, err := resizeImage(orig, 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 {
@@ -71,46 +77,61 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, err)
}
if err != nil || resized == nil {
// if we couldn't resize the image, return the original
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
return orig, "", err
// Force finish reading any remaining data
_, _ = io.Copy(io.Discard, r)
return io.NopCloser(buf), "", nil //nolint:nilerr
}
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
}

View File

@@ -10,7 +10,6 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"time"
@@ -38,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)
@@ -80,14 +79,7 @@ func fromExternalFile(ctx context.Context, files string, pattern string) sourceF
}
}
// These regexes are used to match the picture type in the file, in the order they are listed.
var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`),
regexp.MustCompile(`(?i).*front.*`),
regexp.MustCompile(`(?i).*cover.*`),
}
func fromTag(ctx context.Context, path string) sourceFunc {
func fromTag(path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
@@ -103,31 +95,10 @@ func fromTag(ctx context.Context, path string) sourceFunc {
return nil, "", err
}
types := m.PictureTypes()
if len(types) == 0 {
picture := m.Picture()
if picture == nil {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
}
var picture *tag.Picture
for _, regex := range picTypeRegexes {
for _, t := range types {
if regex.MatchString(t) {
log.Trace(ctx, "Found embedded image", "type", t, "path", path)
picture = m.Pictures(t)
break
}
}
if picture != nil {
break
}
}
if picture == nil {
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
picture = m.Picture()
}
if picture == nil {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
}
return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
}
}
@@ -141,13 +112,19 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
if err != nil {
return nil, "", err
}
return r, path, nil
defer r.Close()
buf := new(bytes.Buffer)
_, err = io.Copy(buf, r)
if err != nil {
return nil, "", err
}
return io.NopCloser(buf), path, nil
}
}
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
}

View File

@@ -1,47 +1,34 @@
package auth
import (
"cmp"
"context"
"crypto/sha256"
"sync"
"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"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
)
var (
once sync.Once
Secret []byte
TokenAuth *jwtauth.JWTAuth
)
// Init creates a JWTAuth object from the secret stored in the DB.
// If the secret is not found, it will create a new one and store it in the DB.
func Init(ds model.DataStore) {
once.Do(func() {
ctx := context.TODO()
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
secret = createNewSecret(ctx, ds)
} else {
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
secret = createNewSecret(ctx, ds)
}
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)
}
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
Secret = []byte(secret)
TokenAuth = jwtauth.New("HS256", Secret, nil)
})
}
@@ -123,25 +110,3 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
ctx = request.WithUsername(ctx, u.UserName)
return request.WithUser(ctx, *u)
}
func createNewSecret(ctx context.Context, ds model.DataStore) string {
secret := uuid.NewString()
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
if err != nil {
log.Error(ctx, "Could not encrypt JWT secret", err)
return secret
}
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err)
}
return secret
}
func getEncKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,
consts.DefaultEncryptionKey,
)
sum := sha256.Sum256([]byte(key))
return sum[:]
}

View File

@@ -4,12 +4,12 @@ import (
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -32,10 +32,8 @@ var _ = BeforeSuite(func() {
var _ = Describe("Auth", func() {
BeforeEach(func() {
ds := &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
}
auth.Init(ds)
auth.Secret = []byte(testJWTSecret)
auth.TokenAuth = jwtauth.New("HS256", auth.Secret, nil)
})
Describe("Validate", func() {

View File

@@ -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,29 +58,29 @@ 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
}
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum, error) {
var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxAlbum{}, err
return nil, err
}
var album 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:
return auxAlbum{}, model.ErrNotFound
return nil, model.ErrNotFound
}
return album, nil
return &album, nil
}
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
@@ -91,37 +90,35 @@ 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)
album, err = e.populateAlbumInfo(ctx, album)
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
}
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbum) error {
start := time.Now()
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return album, nil
return nil
}
if err != nil {
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
return err
}
album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalInfoUpdatedAt = time.Now()
album.ExternalUrl = info.URL
if info.Description != "" {
@@ -152,29 +149,40 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
}
return album, nil
return nil
}
func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, error) {
func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) {
var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxArtist{}, err
return nil, err
}
var artist 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:
return e.getArtist(ctx, v.AlbumArtistID)
default:
return auxArtist{}, model.ErrNotFound
return nil, model.ErrNotFound
}
return artist, nil
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) {
@@ -183,35 +191,34 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
return nil, err
}
err = e.loadSimilar(ctx, &artist, similarCount, includeNotPresent)
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
return &artist.Artist, err
}
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*auxArtist, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return auxArtist{}, err
return nil, err
}
// 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)
artist, err = e.populateArtistInfo(ctx, artist)
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 auxArtist{}, err
return nil, err
}
}
// 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
}
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxArtist) error {
start := time.Now()
// Get MBID first, if it is not yet available
if artist.MbzArtistID == "" {
@@ -224,18 +231,18 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt
// Call all registered agents and collect information
g := errgroup.Group{}
g.SetLimit(2)
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
g.Go(func() error { e.callGetImage(ctx, e.ag, artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, artist); return nil })
g.Go(func() error { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true); return nil })
_ = g.Wait()
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
return artist, ctx.Err()
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,
@@ -243,7 +250,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
}
return artist, nil
return nil
}
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
@@ -252,20 +259,20 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
return nil, err
}
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
e.callGetSimilar(ctx, e.ag, artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
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
@@ -310,7 +317,7 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
return nil, err
}
e.callGetImage(ctx, e.ag, &artist)
e.callGetImage(ctx, e.ag, artist)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
return nil, ctx.Err()
@@ -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,33 +559,28 @@ 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) (T, error)) refreshQueue[T] {
queue := make(chan *T, refreshQueueLength)
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
queue := make(chan T, refreshQueueLength)
go func() {
for {
time.Sleep(refreshDelay)
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
select {
case a := <-queue:
_ = processFn(ctx, a)
cancel()
case <-ctx.Done():
return
case <-time.After(refreshDelay):
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
select {
case item := <-queue:
_, _ = processFn(ctx, *item)
cancel()
case <-ctx.Done():
cancel()
}
cancel()
break
}
}
}()
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
}
}

View File

@@ -16,12 +16,10 @@ import (
)
type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
IsAvailable() bool
Version() string
}
func New() FFmpeg {
@@ -35,11 +33,11 @@ const (
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate, offset)
args := createFFmpegCommand(command, path, maxBitRate)
return e.start(ctx, args)
}
@@ -47,7 +45,7 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
args := createFFmpegCommand(extractImageCmd, path, 0)
return e.start(ctx, args)
}
@@ -66,29 +64,6 @@ func (e *ffmpeg) CmdPath() (string, error) {
return ffmpegCmd()
}
func (e *ffmpeg) IsAvailable() bool {
_, err := ffmpegCmd()
return err == nil
}
// Version executes ffmpeg -version and extracts the version from the output.
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
func (e *ffmpeg) Version() string {
cmd, err := ffmpegCmd()
if err != nil {
return "N/A"
}
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
if err != nil {
return "N/A"
}
parts := strings.Split(string(out), " ")
if len(parts) < 3 {
return "N/A"
}
return parts[2]
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
@@ -111,7 +86,7 @@ type ffCmd struct {
func (j *ffCmd) start() error {
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
if log.CurrentLevel() >= log.LevelTrace {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
@@ -138,27 +113,22 @@ func (j *ffCmd) wait() {
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
for _, s := range fixCmd(cmd) {
if strings.Contains(s, "%s") {
s = strings.ReplaceAll(s, "%s", path)
args = append(args, s)
if offset > 0 && !strings.Contains(cmd, "%t") {
args = append(args, "-ss", strconv.Itoa(offset))
}
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
args = append(args, s)
}
func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
split := strings.Split(fixCmd(cmd), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%s", path)
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
split[i] = s
}
return args
return split
}
func createProbeCommand(cmd string, inputs []string) []string {
split := strings.Split(fixCmd(cmd), " ")
var args []string
for _, s := range fixCmd(cmd) {
for _, s := range split {
if s == "%s" {
for _, inp := range inputs {
args = append(args, "-i", inp)
@@ -170,15 +140,18 @@ func createProbeCommand(cmd string, inputs []string) []string {
return args
}
func fixCmd(cmd string) []string {
split := strings.Fields(cmd)
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
cmdPath, _ := ffmpegCmd()
for i, s := range split {
for _, s := range split {
if s == "ffmpeg" || s == "ffmpeg.exe" {
split[i] = cmdPath
result = append(result, cmdPath)
} else {
result = append(result, s)
}
}
return split
return strings.Join(result, " ")
}
func ffmpegCmd() (string, error) {
@@ -201,7 +174,6 @@ func ffmpegCmd() (string, error) {
return ffmpegPath, ffmpegErr
}
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
var (
ffOnce sync.Once
ffmpegPath string

View File

@@ -24,26 +24,9 @@ var _ = Describe("ffmpeg", func() {
})
Describe("createFFmpegCommand", func() {
It("creates a valid command line", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
It("handles extra spaces in the command string", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
})
})
})
Describe("createProbeCommand", func() {
@@ -52,17 +35,4 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
})
})
When("ffmpegPath is set", func() {
It("returns the correct ffmpeg path", func() {
ffmpegPath = "/usr/bin/ffmpeg"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
})
It("returns the correct ffmpeg path with spaces", func() {
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
})

View File

@@ -19,8 +19,8 @@ import (
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error)
}
type TranscodingCache cache.FileCache
@@ -40,23 +40,22 @@ type streamJob struct {
mf *model.MediaFile
format string
bitRate int
offset int
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
return ms.DoStream(ctx, mf, reqFormat, reqBitRate)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) {
var format string
var bitRate int
var cached bool
@@ -71,7 +70,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(mf.Path)
@@ -89,7 +88,6 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
mf: mf,
format: format,
bitRate: bitRate,
offset: reqOffset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
@@ -102,7 +100,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@@ -131,11 +129,11 @@ func (s *Stream) EstimatedContentLength() int {
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return format, 0
return
}
if reqFormat == mf.Suffix && reqBitRate == 0 {
bitRate = mf.BitRate
return format, bitRate
return
}
trc, hasDefault := request.TranscodingFrom(ctx)
var cFormat string
@@ -161,7 +159,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
cBitRate = reqBitRate
}
if cBitRate == 0 && cFormat == "" {
return format, bitRate
return
}
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
if err == nil {
@@ -186,26 +184,22 @@ var (
func GetTranscodingCache() TranscodingCache {
onceTranscodingCache.Do(func() {
instanceTranscodingCache = NewTranscodingCache()
instanceTranscodingCache = cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
})
return instanceTranscodingCache
}
func NewTranscodingCache() TranscodingCache {
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
}

View File

@@ -2,8 +2,10 @@ package core
import (
"context"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -14,10 +16,21 @@ import (
var _ = Describe("MediaStreamer", func() {
var ds model.DataStore
ctx := log.NewContext(context.Background())
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
})
testCache := GetTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.DataFolder)
})
Context("selectTranscodingOptions", func() {
@@ -122,11 +135,10 @@ var _ = Describe("MediaStreamer", func() {
Expect(bitRate).To(Equal(0))
})
})
Context("player has maxBitRate configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
ctx = request.WithTranscoding(ctx, t)
ctx = request.WithPlayer(ctx, p)
})
@@ -141,7 +153,7 @@ var _ = Describe("MediaStreamer", func() {
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(192))
Expect(bitRate).To(Equal(80))
})
It("returns requested format", func() {
mf.Suffix = "flac"
@@ -153,9 +165,9 @@ var _ = Describe("MediaStreamer", func() {
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(160))
Expect(bitRate).To(Equal(80))
})
})
})

View File

@@ -23,50 +23,50 @@ var _ = Describe("MediaStreamer", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
})
testCache := core.NewTranscodingCache()
testCache := core.GetTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
_ = os.RemoveAll(conf.Server.DataFolder)
})
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
s, err := streamer.NewStream(ctx, "123", "raw", 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

123
core/metrics.go Normal file
View File

@@ -0,0 +1,123 @@
package core
import (
"context"
"fmt"
"strconv"
"sync"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/prometheus/client_golang/prometheus"
)
func WriteInitialMetrics() {
getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1)
}
func WriteAfterScanMetrics(ctx context.Context, dataStore model.DataStore, success bool) {
processSqlAggregateMetrics(ctx, dataStore, getPrometheusMetrics().dbTotal)
scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)}
getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime()
getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc()
}
// Prometheus' metrics requires initialization. But not more than once
var (
prometheusMetricsInstance *prometheusMetrics
prometheusOnce sync.Once
)
type prometheusMetrics struct {
dbTotal *prometheus.GaugeVec
versionInfo *prometheus.GaugeVec
lastMediaScan *prometheus.GaugeVec
mediaScansCounter *prometheus.CounterVec
}
func getPrometheusMetrics() *prometheusMetrics {
prometheusOnce.Do(func() {
var err error
prometheusMetricsInstance, err = newPrometheusMetrics()
if err != nil {
log.Fatal("Unable to create Prometheus metrics instance.", err)
}
})
return prometheusMetricsInstance
}
func newPrometheusMetrics() (*prometheusMetrics, error) {
res := &prometheusMetrics{
dbTotal: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "db_model_totals",
Help: "Total number of DB items per model",
},
[]string{"model"},
),
versionInfo: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "navidrome_info",
Help: "Information about Navidrome version",
},
[]string{"version"},
),
lastMediaScan: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "media_scan_last",
Help: "Last media scan timestamp by success",
},
[]string{"success"},
),
mediaScansCounter: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "media_scans",
Help: "Total success media scans by success",
},
[]string{"success"},
),
}
err := prometheus.DefaultRegisterer.Register(res.dbTotal)
if err != nil {
return nil, fmt.Errorf("unable to register db_model_totals metrics: %w", err)
}
err = prometheus.DefaultRegisterer.Register(res.versionInfo)
if err != nil {
return nil, fmt.Errorf("unable to register navidrome_info metrics: %w", err)
}
err = prometheus.DefaultRegisterer.Register(res.lastMediaScan)
if err != nil {
return nil, fmt.Errorf("unable to register media_scan_last metrics: %w", err)
}
err = prometheus.DefaultRegisterer.Register(res.mediaScansCounter)
if err != nil {
return nil, fmt.Errorf("unable to register media_scans metrics: %w", err)
}
return res, nil
}
func processSqlAggregateMetrics(ctx context.Context, dataStore model.DataStore, targetGauge *prometheus.GaugeVec) {
albumsCount, err := dataStore.Album(ctx).CountAll()
if err != nil {
log.Warn("album CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount))
songsCount, err := dataStore.MediaFile(ctx).CountAll()
if err != nil {
log.Warn("media CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount))
usersCount, err := dataStore.User(ctx).CountAll()
if err != nil {
log.Warn("user CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount))
}

View File

@@ -1,262 +0,0 @@
package metrics
import (
"bytes"
"context"
"encoding/json"
"math"
"net/http"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
)
type Insights interface {
Run(ctx context.Context)
LastRun(ctx context.Context) (timestamp time.Time, success bool)
}
var (
insightsID string
)
type insightsCollector struct {
ds model.DataStore
lastRun atomic.Int64
lastStatus atomic.Bool
}
func GetInstance(ds model.DataStore) Insights {
return singleton.GetInstance(func() *insightsCollector {
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
if err != nil {
log.Trace("Could not get Insights ID from DB. Creating one", err)
id = uuid.NewString()
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
if err != nil {
log.Trace("Could not save Insights ID to DB", err)
}
}
insightsID = id
return &insightsCollector{ds: ds}
})
}
func (c *insightsCollector) Run(ctx context.Context) {
ctx = auth.WithAdminUser(ctx, c.ds)
for {
c.sendInsights(ctx)
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
case <-ctx.Done():
return
}
}
}
func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
t := c.lastRun.Load()
return time.UnixMilli(t), c.lastStatus.Load()
}
func (c *insightsCollector) sendInsights(ctx context.Context) {
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
if err != nil {
log.Trace(ctx, "Could not check user count", err)
return
}
if count == 0 {
log.Trace(ctx, "No users found, skipping Insights data collection")
return
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
data := c.collect(ctx)
if data == nil {
return
}
body := bytes.NewReader(data)
req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
if err != nil {
log.Trace(ctx, "Could not create Insights request", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return
}
log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
c.lastRun.Store(time.Now().UnixMilli())
c.lastStatus.Store(resp.StatusCode < 300)
resp.Body.Close()
}
func buildInfo() (map[string]string, string) {
bInfo := map[string]string{}
var version string
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Value == "" {
continue
}
bInfo[setting.Key] = setting.Value
}
version = info.GoVersion
}
return bInfo, version
}
func getFSInfo(path string) *insights.FSInfo {
var info insights.FSInfo
// Normalize the path
absPath, err := filepath.Abs(path)
if err != nil {
return nil
}
absPath = filepath.Clean(absPath)
fsType, err := getFilesystemType(absPath)
if err != nil {
return nil
}
info.Type = fsType
return &info
}
var staticData = sync.OnceValue(func() insights.Data {
// Basic info
data := insights.Data{
InsightsID: insightsID,
Version: consts.Version,
}
// Build info
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
data.OS.NumCPU = runtime.NumCPU()
data.OS.Version, data.OS.Distro = getOSVersion()
// FS info
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
data.FS.Data = getFSInfo(conf.Server.DataFolder)
if conf.Server.CacheFolder != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
}
if conf.Server.Backup.Path != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
}
// Config info
data.Config.LogLevel = conf.Server.LogLevel
data.Config.LogFileConfigured = conf.Server.LogFile != ""
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
data.Config.EnableDownloads = conf.Server.EnableDownloads
data.Config.EnableSharing = conf.Server.EnableSharing
data.Config.EnableStarRating = conf.Server.EnableStarRating
data.Config.EnableLastFM = conf.Server.LastFM.Enabled
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
data.Config.EnableSpotify = conf.Server.Spotify.ID != ""
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.ScanSchedule = conf.Server.ScanSchedule
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
data.Config.SearchFullString = conf.Server.SearchFullString
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule
data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
return data
})
func (c *insightsCollector) collect(ctx context.Context) []byte {
data := staticData()
data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000
// Library info
var err error
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading tracks count", err)
}
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading albums count", err)
}
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading artists count", err)
}
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading playlists count", err)
}
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading shares count", err)
}
data.Library.Radios, err = c.ds.Radio(ctx).Count()
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active users count", err)
}
if conf.Server.DevEnablePlayerInsights {
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active players count", err)
}
}
// Memory info
var m runtime.MemStats
runtime.ReadMemStats(&m)
data.Mem.Alloc = m.Alloc
data.Mem.TotalAlloc = m.TotalAlloc
data.Mem.Sys = m.Sys
data.Mem.NumGC = m.NumGC
// Marshal to JSON
resp, err := json.Marshal(data)
if err != nil {
log.Trace(ctx, "Could not marshal Insights data", err)
return nil
}
return resp
}

View File

@@ -1,73 +0,0 @@
package insights
type Data struct {
InsightsID string `json:"id"`
Version string `json:"version"`
Uptime int64 `json:"uptime"`
Build struct {
// build settings used by the Go compiler
Settings map[string]string `json:"settings"`
GoVersion string `json:"goVersion"`
} `json:"build"`
OS struct {
Type string `json:"type"`
Distro string `json:"distro,omitempty"`
Version string `json:"version,omitempty"`
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`
TotalAlloc uint64 `json:"totalAlloc"`
Sys uint64 `json:"sys"`
NumGC uint32 `json:"numGC"`
} `json:"mem"`
FS struct {
Music *FSInfo `json:"music,omitempty"`
Data *FSInfo `json:"data,omitempty"`
Cache *FSInfo `json:"cache,omitempty"`
Backup *FSInfo `json:"backup,omitempty"`
} `json:"fs"`
Library struct {
Tracks int64 `json:"tracks"`
Albums int64 `json:"albums"`
Artists int64 `json:"artists"`
Playlists int64 `json:"playlists"`
Shares int64 `json:"shares"`
Radios int64 `json:"radios"`
ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
} `json:"library"`
Config struct {
LogLevel string `json:"logLevel,omitempty"`
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
TLSConfigured bool `json:"tlsConfigured,omitempty"`
ScanSchedule string `json:"scanSchedule,omitempty"`
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
ImageCacheSize string `json:"imageCacheSize,omitempty"`
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
EnableDownloads bool `json:"enableDownloads,omitempty"`
EnableSharing bool `json:"enableSharing,omitempty"`
EnableStarRating bool `json:"enableStarRating,omitempty"`
EnableLastFM bool `json:"enableLastFM,omitempty"`
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
EnableSpotify bool `json:"enableSpotify,omitempty"`
EnableJukebox bool `json:"enableJukebox,omitempty"`
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
SearchFullString bool `json:"searchFullString,omitempty"`
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
PreferSortTags bool `json:"preferSortTags,omitempty"`
BackupSchedule string `json:"backupSchedule,omitempty"`
BackupCount int `json:"backupCount,omitempty"`
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
} `json:"config"`
}
type FSInfo struct {
Type string `json:"type,omitempty"`
}

View File

@@ -1,37 +0,0 @@
package metrics
import (
"os/exec"
"strings"
"syscall"
)
func getOSVersion() (string, string) {
cmd := exec.Command("sw_vers", "-productVersion")
output, err := cmd.Output()
if err != nil {
return "", ""
}
return strings.TrimSpace(string(output)), ""
}
func getFilesystemType(path string) (string, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return "", err
}
// Convert the filesystem type name from [16]int8 to string
fsType := make([]byte, 0, 16)
for _, c := range stat.Fstypename {
if c == 0 {
break
}
fsType = append(fsType, byte(c))
}
return string(fsType), nil
}

View File

@@ -1,9 +0,0 @@
//go:build !linux && !windows && !darwin
package metrics
import "errors"
func getOSVersion() (string, string) { return "", "" }
func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") }

View File

@@ -1,91 +0,0 @@
package metrics
import (
"fmt"
"io"
"os"
"strings"
"syscall"
)
func getOSVersion() (string, string) {
file, err := os.Open("/etc/os-release")
if err != nil {
return "", ""
}
defer file.Close()
osRelease, err := io.ReadAll(file)
if err != nil {
return "", ""
}
lines := strings.Split(string(osRelease), "\n")
version := ""
distro := ""
for _, line := range lines {
if strings.HasPrefix(line, "VERSION_ID=") {
version = strings.ReplaceAll(strings.TrimPrefix(line, "VERSION_ID="), "\"", "")
}
if strings.HasPrefix(line, "ID=") {
distro = strings.ReplaceAll(strings.TrimPrefix(line, "ID="), "\"", "")
}
}
return version, distro
}
// MountInfo represents an entry from /proc/self/mountinfo
type MountInfo struct {
MountPoint string
FSType string
}
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
0xff534d42: "cifs",
0x28cd3d45: "cramfs",
0x64626720: "debugfs",
0xf15f: "ecryptfs",
0x2011bab0: "exfat",
0x0000EF53: "ext2/ext3/ext4",
0xf2f52010: "f2fs",
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
0x517b: "smb",
0xfe534d42: "smb2",
0x73717368: "squashfs",
0x62656572: "sysfs",
0x01021994: "tmpfs",
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
}
func getFilesystemType(path string) (string, error) {
var fsStat syscall.Statfs_t
err := syscall.Statfs(path, &fsStat)
if err != nil {
return "", err
}
fsType := fsStat.Type
fsName, exists := fsTypeMap[int64(fsType)] //nolint:unconvert
if !exists {
fsName = fmt.Sprintf("unknown(0x%x)", fsType)
}
return fsName, nil
}

View File

@@ -1,53 +0,0 @@
package metrics
import (
"os/exec"
"regexp"
"golang.org/x/sys/windows"
)
// Ex: Microsoft Windows [Version 10.0.26100.1742]
var winVerRegex = regexp.MustCompile(`Microsoft Windows \[.+\s([\d\.]+)\]`)
func getOSVersion() (version string, _ string) {
cmd := exec.Command("cmd", "/c", "ver")
output, err := cmd.Output()
if err != nil {
return "", ""
}
matches := winVerRegex.FindStringSubmatch(string(output))
if len(matches) != 2 {
return string(output), ""
}
return matches[1], ""
}
func getFilesystemType(path string) (string, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", err
}
var volumeName, filesystemName [windows.MAX_PATH + 1]uint16
var serialNumber uint32
var maxComponentLen, filesystemFlags uint32
err = windows.GetVolumeInformation(
pathPtr,
&volumeName[0],
windows.MAX_PATH,
&serialNumber,
&maxComponentLen,
&filesystemFlags,
&filesystemName[0],
windows.MAX_PATH)
if err != nil {
return "", err
}
return windows.UTF16ToString(filesystemName[:]), nil
}

View File

@@ -1,146 +0,0 @@
package metrics
import (
"context"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type Metrics interface {
WriteInitialMetrics(ctx context.Context)
WriteAfterScanMetrics(ctx context.Context, success bool)
GetHandler() http.Handler
}
type metrics struct {
ds model.DataStore
}
func NewPrometheusInstance(ds model.DataStore) Metrics {
return &metrics{ds: ds}
}
func (m *metrics) WriteInitialMetrics(ctx context.Context) {
getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1)
processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal)
}
func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) {
processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal)
scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)}
getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime()
getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc()
}
func (m *metrics) GetHandler() http.Handler {
r := chi.NewRouter()
if conf.Server.Prometheus.Password != "" {
r.Use(middleware.BasicAuth("metrics", map[string]string{
consts.PrometheusAuthUser: conf.Server.Prometheus.Password,
}))
}
r.Handle("/", promhttp.Handler())
return r
}
type prometheusMetrics struct {
dbTotal *prometheus.GaugeVec
versionInfo *prometheus.GaugeVec
lastMediaScan *prometheus.GaugeVec
mediaScansCounter *prometheus.CounterVec
}
// Prometheus' metrics requires initialization. But not more than once
var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
instance := &prometheusMetrics{
dbTotal: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "db_model_totals",
Help: "Total number of DB items per model",
},
[]string{"model"},
),
versionInfo: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "navidrome_info",
Help: "Information about Navidrome version",
},
[]string{"version"},
),
lastMediaScan: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "media_scan_last",
Help: "Last media scan timestamp by success",
},
[]string{"success"},
),
mediaScansCounter: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "media_scans",
Help: "Total success media scans by success",
},
[]string{"success"},
),
}
err := prometheus.DefaultRegisterer.Register(instance.dbTotal)
if err != nil {
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register db_model_totals metrics: %w", err))
}
err = prometheus.DefaultRegisterer.Register(instance.versionInfo)
if err != nil {
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register navidrome_info metrics: %w", err))
}
err = prometheus.DefaultRegisterer.Register(instance.lastMediaScan)
if err != nil {
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scan_last metrics: %w", err))
}
err = prometheus.DefaultRegisterer.Register(instance.mediaScansCounter)
if err != nil {
log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scans metrics: %w", err))
}
return instance
})
func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) {
albumsCount, err := ds.Album(ctx).CountAll()
if err != nil {
log.Warn("album CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount))
artistCount, err := ds.Artist(ctx).CountAll()
if err != nil {
log.Warn("artist CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount))
songsCount, err := ds.MediaFile(ctx).CountAll()
if err != nil {
log.Warn("media CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount))
usersCount, err := ds.User(ctx).CountAll()
if err != nil {
log.Warn("user CountAll error", err)
return
}
targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount))
}

View File

@@ -1,299 +0,0 @@
package playback
import (
"context"
"errors"
"fmt"
"sync"
"github.com/navidrome/navidrome/core/playback/mpv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Track interface {
IsPlaying() bool
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
Pause()
Unpause()
Position() int
SetPosition(offset int) error
Close()
String() string
}
type playbackDevice struct {
serviceCtx context.Context
ParentPlaybackServer PlaybackServer
Default bool
User string
Name string
DeviceName string
PlaybackQueue *Queue
Gain float32
PlaybackDone chan bool
ActiveTrack Track
startTrackSwitcher sync.Once
}
type DeviceStatus struct {
CurrentIndex int
Playing bool
Gain float32
Position int
}
const DefaultGain float32 = 1.0
func (pd *playbackDevice) getStatus() DeviceStatus {
pos := 0
if pd.ActiveTrack != nil {
pos = pd.ActiveTrack.Position()
}
return DeviceStatus{
CurrentIndex: pd.PlaybackQueue.Index,
Playing: pd.isPlaying(),
Gain: pd.Gain,
Position: pos,
}
}
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
// Starts the trackSwitcher goroutine for the device.
func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
return &playbackDevice{
serviceCtx: ctx,
ParentPlaybackServer: playbackServer,
User: "",
Name: name,
DeviceName: deviceName,
Gain: DefaultGain,
PlaybackQueue: NewQueue(),
PlaybackDone: make(chan bool),
}
}
func (pd *playbackDevice) String() string {
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
}
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
log.Debug(ctx, "Processing Get action", "device", pd)
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
}
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
return pd.getStatus(), nil
}
// Set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd)
_, err := pd.Clear(ctx)
if err != nil {
log.Error(ctx, "error setting tracks", ids)
return pd.getStatus(), err
}
return pd.Add(ctx, ids)
}
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Start action", "device", pd)
pd.startTrackSwitcher.Do(func() {
log.Info(ctx, "Starting trackSwitcher goroutine")
// Start one trackSwitcher goroutine with each device
go func() {
pd.trackSwitcherGoroutine()
}()
})
if pd.ActiveTrack != nil {
if pd.isPlaying() {
log.Debug("trying to start an already playing track")
} else {
pd.ActiveTrack.Unpause()
}
} else {
if !pd.PlaybackQueue.IsEmpty() {
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
return pd.getStatus(), err
}
pd.ActiveTrack.Unpause()
}
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Stop action", "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
wasPlaying := pd.isPlaying()
if pd.ActiveTrack != nil && wasPlaying {
pd.ActiveTrack.Pause()
}
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if pd.ActiveTrack == nil {
err := pd.switchActiveTrackByIndex(index)
if err != nil {
return pd.getStatus(), err
}
}
err := pd.ActiveTrack.SetPosition(offset)
if err != nil {
log.Error(ctx, "error setting position", err)
return pd.getStatus(), err
}
if wasPlaying {
_, err = pd.Start(ctx)
if err != nil {
log.Error(ctx, "error starting new track after skipping")
return pd.getStatus(), err
}
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd)
if len(ids) < 1 {
return pd.getStatus(), nil
}
items := model.MediaFiles{}
for _, id := range ids {
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
if err != nil {
return DeviceStatus{}, err
}
log.Debug(ctx, "Found mediafile: "+mf.Path)
items = append(items, *mf)
}
pd.PlaybackQueue.Add(items)
return pd.getStatus(), nil
}
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Clear action", "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
pd.PlaybackQueue.Clear()
return pd.getStatus(), nil
}
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
// pausing if attempting to remove running track
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
_, err := pd.Stop(ctx)
if err != nil {
log.Error(ctx, "error stopping running track")
return pd.getStatus(), err
}
}
if index > -1 && index < pd.PlaybackQueue.Size() {
pd.PlaybackQueue.Remove(index)
} else {
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Shuffle action", "device", pd)
if pd.PlaybackQueue.Size() > 1 {
pd.PlaybackQueue.Shuffle()
}
return pd.getStatus(), nil
}
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.SetVolume(gain)
}
pd.Gain = gain
return pd.getStatus(), nil
}
func (pd *playbackDevice) isPlaying() bool {
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
}
func (pd *playbackDevice) trackSwitcherGoroutine() {
log.Debug("Started trackSwitcher goroutine", "device", pd)
for {
select {
case <-pd.PlaybackDone:
log.Debug("Track switching detected")
if pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if !pd.PlaybackQueue.IsAtLastElement() {
pd.PlaybackQueue.IncreaseIndex()
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
log.Error("Error switching track", err)
}
if pd.ActiveTrack != nil {
pd.ActiveTrack.Unpause()
}
} else {
log.Debug("There is no song left in the playlist. Finish.")
}
case <-pd.serviceCtx.Done():
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
return
}
}
}
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
pd.PlaybackQueue.SetIndex(index)
currentTrack := pd.PlaybackQueue.Current()
if currentTrack == nil {
return errors.New("could not get current track")
}
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
if err != nil {
return err
}
pd.ActiveTrack = track
pd.ActiveTrack.SetVolume(pd.Gain)
return nil
}

View File

@@ -1,123 +0,0 @@
package mpv
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
func start(ctx context.Context, args []string) (Executor, error) {
log.Debug("Executing mpv command", "cmd", args)
j := Executor{args: args}
j.PipeReader, j.out = io.Pipe()
err := j.start(ctx)
if err != nil {
return Executor{}, err
}
go j.wait()
return j, nil
}
func (j *Executor) Cancel() error {
if j.cmd != nil {
return j.cmd.Cancel()
}
return fmt.Errorf("there is non command to cancel")
}
type Executor struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
}
func (j *Executor) start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
}
j.cmd = cmd
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting cmd: %w", err)
}
return nil
}
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
return
}
_ = j.out.Close()
}
// Path will always be an absolute path
func createMPVCommand(deviceName string, filename string, socketName string) []string {
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%d", deviceName)
s = strings.ReplaceAll(s, "%f", filename)
s = strings.ReplaceAll(s, "%s", socketName)
split[i] = s
}
return split
}
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
cmdPath, _ := mpvCommand()
for _, s := range split {
if s == "mpv" || s == "mpv.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
}
}
return strings.Join(result, " ")
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
func mpvCommand() (string, error) {
mpvOnce.Do(func() {
if conf.Server.MPVPath != "" {
mpvPath = conf.Server.MPVPath
mpvPath, mpvErr = exec.LookPath(mpvPath)
} else {
mpvPath, mpvErr = exec.LookPath("mpv")
if errors.Is(mpvErr, exec.ErrDot) {
log.Trace("mpv found in current folder '.'")
mpvPath, mpvErr = exec.LookPath("./mpv")
}
}
if mpvErr == nil {
log.Info("Found mpv", "path", mpvPath)
return
}
})
return mpvPath, mpvErr
}
var (
mpvOnce sync.Once
mpvPath string
mpvErr error
)

View File

@@ -1,22 +0,0 @@
//go:build !windows
package mpv
import (
"os"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
)
func socketName(prefix, suffix string) string {
return utils.TempFileName(prefix, suffix)
}
func removeSocket(socketName string) {
log.Debug("Removing socketfile", "socketfile", socketName)
err := os.Remove(socketName)
if err != nil {
log.Error("Error cleaning up socketfile", "socketfile", socketName, err)
}
}

View File

@@ -1,19 +0,0 @@
//go:build windows
package mpv
import (
"path/filepath"
"github.com/google/uuid"
)
func socketName(prefix, suffix string) string {
// Windows needs to use a named pipe for the socket
// see https://mpv.io/manual/master#using-mpv-from-other-programs-or-scripts
return filepath.Join(`\\.\pipe\mpvsocket`, prefix+uuid.NewString()+suffix)
}
func removeSocket(string) {
// Windows automatically handles cleaning up named pipe
}

View File

@@ -1,220 +0,0 @@
package mpv
// Audio-playback using mpv media-server. See mpv.io
// https://github.com/dexterlb/mpvipc
// https://mpv.io/manual/master/#json-ipc
// https://mpv.io/manual/master/#properties
import (
"context"
"fmt"
"os"
"time"
"github.com/dexterlb/mpvipc"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type MpvTrack struct {
MediaFile model.MediaFile
PlaybackDone chan bool
Conn *mpvipc.Connection
IPCSocketName string
Exe *Executor
CloseCalled bool
}
func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
if _, err := mpvCommand(); err != nil {
return nil, err
}
tmpSocketName := socketName("mpv-ctrl-", ".socket")
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
exe, err := start(ctx, args)
if err != nil {
log.Error("Error starting mpv process", err)
return nil, err
}
// wait for socket to show up
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
if err != nil {
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
return nil, err
}
conn := mpvipc.NewConnection(tmpSocketName)
err = conn.Open()
if err != nil {
log.Error("Error opening new connection", err)
return nil, err
}
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
go func() {
conn.WaitUntilClosed()
log.Info("Hitting end-of-stream, signalling on channel")
if !theTrack.CloseCalled {
playbackDoneChannel <- true
}
}()
return theTrack, nil
}
func (t *MpvTrack) String() string {
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
}
// Used to control the playback volume. A float value between 0.0 and 1.0.
func (t *MpvTrack) SetVolume(value float32) {
// mpv's volume as described in the --volume parameter:
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
// Negative values can be passed for compatibility, but are treated as 0.
log.Debug("Setting volume", "volume", value, "track", t)
vol := int(value * 100)
err := t.Conn.Set("volume", vol)
if err != nil {
log.Error("Error setting volume", "volume", value, "track", t, err)
}
}
func (t *MpvTrack) Unpause() {
log.Debug("Unpausing track", "track", t)
err := t.Conn.Set("pause", false)
if err != nil {
log.Error("Error unpausing track", "track", t, err)
}
}
func (t *MpvTrack) Pause() {
log.Debug("Pausing track", "track", t)
err := t.Conn.Set("pause", true)
if err != nil {
log.Error("Error pausing track", "track", t, err)
}
}
func (t *MpvTrack) Close() {
log.Debug("Closing resources", "track", t)
t.CloseCalled = true
// trying to shutdown mpv process using socket
if t.isSocketFilePresent() {
log.Debug("sending shutdown command")
_, err := t.Conn.Call("quit")
if err != nil {
log.Warn("Error sending quit command to mpv-ipc socket", err)
if t.Exe != nil {
log.Debug("cancelling executor")
err = t.Exe.Cancel()
if err != nil {
log.Warn("Error canceling executor", err)
}
}
}
}
if t.isSocketFilePresent() {
removeSocket(t.IPCSocketName)
}
}
func (t *MpvTrack) isSocketFilePresent() bool {
if len(t.IPCSocketName) < 1 {
return false
}
fileInfo, err := os.Stat(t.IPCSocketName)
return err == nil && fileInfo != nil && !fileInfo.IsDir()
}
// Position returns the playback position in seconds.
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
// in this case we have to retry
func (t *MpvTrack) Position() int {
retryCount := 0
for {
position, err := t.Conn.Get("time-pos")
if err != nil && err.Error() == "mpv error: property unavailable" {
retryCount += 1
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
if retryCount > 5 {
return 0
}
time.Sleep(time.Duration(retryCount) * time.Millisecond)
continue
}
if err != nil {
log.Error("Error getting position in track", "track", t, err)
return 0
}
pos, ok := position.(float64)
if !ok {
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
return 0
} else {
return int(pos)
}
}
}
func (t *MpvTrack) SetPosition(offset int) error {
log.Debug("Setting position", "offset", offset, "track", t)
pos := t.Position()
if pos == offset {
log.Debug("No position difference, skipping operation", "track", t)
return nil
}
err := t.Conn.Set("time-pos", float64(offset))
if err != nil {
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
return err
}
return nil
}
func (t *MpvTrack) IsPlaying() bool {
log.Debug("Checking if track is playing", "track", t)
pausing, err := t.Conn.Get("pause")
if err != nil {
log.Error("Problem getting paused status", "track", t, err)
return false
}
pause, ok := pausing.(bool)
if !ok {
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
return false
}
return !pause
}
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
var retries int = 0
for {
fileInfo, err := os.Stat(path)
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
return nil
}
if time.Now().After(end) {
return fmt.Errorf("timeout reached: %s", timeout)
}
time.Sleep(pause)
retries += 1
}
}

View File

@@ -1,17 +0,0 @@
package playback
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlayback(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Playback Suite")
}

View File

@@ -1,127 +0,0 @@
// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn.
// It makes use of the MPV library to do the playback. Major parts are:
// - decoder which includes decoding and transcoding of various audio file formats
// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ...
// - queue a simple playlist
package playback
import (
"context"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
)
type PlaybackServer interface {
Run(ctx context.Context) error
GetDeviceForUser(user string) (*playbackDevice, error)
GetMediaFile(id string) (*model.MediaFile, error)
}
type playbackServer struct {
ctx *context.Context
datastore model.DataStore
playbackDevices []playbackDevice
}
// GetInstance returns the playback-server singleton
func GetInstance(ds model.DataStore) PlaybackServer {
return singleton.GetInstance(func() *playbackServer {
return &playbackServer{datastore: ds}
})
}
// Run starts the playback server which serves request until canceled using the given context
func (ps *playbackServer) Run(ctx context.Context) error {
ps.ctx = &ctx
devices, err := ps.initDeviceStatus(ctx, conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
if err != nil {
return err
}
ps.playbackDevices = devices
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
defaultDevice, _ := ps.getDefaultDevice()
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
<-ctx.Done()
// Should confirm all subprocess are terminated before returning
return nil
}
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
pbDevices := make([]playbackDevice, max(1, len(devices)))
defaultDeviceFound := false
if defaultDevice == "" {
// if there are no devices given and no default device, we create a synthetic device named "auto"
if len(devices) == 0 {
pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto")
}
// if there is but only one entry and no default given, just use that.
if len(devices) == 1 {
if len(devices[0]) != 2 {
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0]))
}
pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1])
}
if len(devices) > 1 {
return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices))
}
pbDevices[0].Default = true
return pbDevices, nil
}
for idx, audioDevice := range devices {
if len(audioDevice) != 2 {
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice))
}
pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1])
if audioDevice[0] == defaultDevice {
pbDevices[idx].Default = true
defaultDeviceFound = true
}
}
if !defaultDeviceFound {
return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
}
return pbDevices, nil
}
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
for idx := range ps.playbackDevices {
if ps.playbackDevices[idx].Default {
return &ps.playbackDevices[idx], nil
}
}
return nil, fmt.Errorf("no default device found")
}
// GetMediaFile retrieves the MediaFile given by the id parameter
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
return ps.datastore.MediaFile(*ps.ctx).Get(id)
}
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) {
log.Debug("Processing GetDevice", "user", user)
// README: here we might plug-in the user-device mapping one fine day
device, err := ps.getDefaultDevice()
if err != nil {
return nil, err
}
device.User = user
return device, nil
}

View File

@@ -1,136 +0,0 @@
package playback
import (
"fmt"
"math/rand"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Queue struct {
Index int
Items model.MediaFiles
}
func NewQueue() *Queue {
return &Queue{
Index: -1,
Items: model.MediaFiles{},
}
}
func (pd *Queue) String() string {
filenames := ""
for idx, item := range pd.Items {
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
}
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
}
// returns the current mediafile or nil
func (pd *Queue) Current() *model.MediaFile {
if pd.Index == -1 {
return nil
}
if pd.Index >= len(pd.Items) {
log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items))
return nil
}
return &pd.Items[pd.Index]
}
// returns the whole queue
func (pd *Queue) Get() model.MediaFiles {
return pd.Items
}
func (pd *Queue) Size() int {
return len(pd.Items)
}
func (pd *Queue) IsEmpty() bool {
return len(pd.Items) < 1
}
// set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *Queue) Set(items model.MediaFiles) {
pd.Clear()
pd.Items = append(pd.Items, items...)
}
// adding mediafiles to the queue
func (pd *Queue) Add(items model.MediaFiles) {
pd.Items = append(pd.Items, items...)
if pd.Index == -1 && len(pd.Items) > 0 {
pd.Index = 0
}
}
// empties whole queue
func (pd *Queue) Clear() {
pd.Index = -1
pd.Items = nil
}
// idx Zero-based index of the song to skip to or remove.
func (pd *Queue) Remove(idx int) {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...)
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
// we seem to have deleted the current id, setting to default:
pd.Index = -1
}
}
func (pd *Queue) Shuffle() {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
log.Error("Could not find ID while shuffling: %s", backupID)
}
}
func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
for idx, item := range pd.Items {
if item.ID == id {
return idx, nil
}
}
return -1, fmt.Errorf("ID not found in playlist: %s", id)
}
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
// values above will be limited by number of items.
func (pd *Queue) SetIndex(idx int) {
pd.Index = max(0, min(idx, len(pd.Items)-1))
}
// Are we at the last track?
func (pd *Queue) IsAtLastElement() bool {
return (pd.Index + 1) >= len(pd.Items)
}
// Goto next index
func (pd *Queue) IncreaseIndex() {
if !pd.IsAtLastElement() {
pd.SetIndex(pd.Index + 1)
}
}

View File

@@ -1,121 +0,0 @@
package playback
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Queues", func() {
var queue *Queue
BeforeEach(func() {
queue = NewQueue()
})
Describe("use empty queue", func() {
It("is empty", func() {
Expect(queue.Items).To(BeEmpty())
Expect(queue.Index).To(Equal(-1))
})
})
Describe("Operate on small queue", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(2))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("1")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(0))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("1"))
Expect(mf.Artist).To(Equal("Queen"))
Expect(mf.Path).To(Equal("/music1/hammer.mp3"))
})
})
Describe("Read/Write operations", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
{
ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3",
},
{
ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3",
},
{
ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(5))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("5")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(4))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("5"))
Expect(mf.Artist).To(Equal("Red Hot Chili Peppers"))
Expect(mf.Path).To(Equal("/music1/californication.mp3"))
})
It("could shuffle the data correctly", func() {
queue.Shuffle()
Expect(queue.Size()).To(Equal(5))
})
It("could remove entries correctly", func() {
queue.Remove(0)
Expect(queue.Size()).To(Equal(4))
queue.Remove(3)
Expect(queue.Size()).To(Equal(3))
})
It("clear the whole thing on request", func() {
Expect(queue.Size()).To(Equal(5))
queue.Clear()
Expect(queue.Size()).To(Equal(0))
})
})
})

View File

@@ -13,7 +13,7 @@ import (
type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
}
func NewPlayers(ds model.DataStore) Players {
@@ -28,7 +28,7 @@ func (p *players) Register(ctx context.Context, id, client, userAgent, ip string
var plr *model.Player
var trc *model.Transcoding
var err error
user, _ := request.UserFrom(ctx)
userName, _ := request.UsernameFrom(ctx)
if id != "" {
plr, err = p.ds.Player(ctx).Get(id)
if err == nil && plr.Client != client {
@@ -36,22 +36,22 @@ func (p *players) Register(ctx context.Context, id, client, userAgent, ip string
}
}
if err != nil || id == "" {
plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent)
plr, err = p.ds.Player(ctx).FindMatch(userName, client, userAgent)
if err == nil {
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName(ctx), "type", userAgent)
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
} else {
plr = &model.Player{
ID: uuid.NewString(),
UserId: user.ID,
UserName: userName,
Client: client,
ScrobbleEnabled: true,
}
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName(ctx), "type", userAgent)
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
}
}
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
plr.UserAgent = userAgent
plr.IP = ip
plr.IPAddress = ip
plr.LastSeen = time.Now()
err = p.ds.Player(ctx).Put(plr)
if err != nil {

View File

@@ -34,7 +34,7 @@ var _ = Describe("Players", func() {
Expect(p.ID).ToNot(BeEmpty())
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(p.Client).To(Equal("client"))
Expect(p.UserId).To(Equal("userid"))
Expect(p.UserName).To(Equal("johndoe"))
Expect(p.UserAgent).To(Equal("chrome"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
@@ -73,7 +73,7 @@ var _ = Describe("Players", func() {
})
It("finds player by client and user names when ID is not found", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@@ -83,7 +83,7 @@ var _ = Describe("Players", func() {
})
It("finds player by client and user names when not ID is provided", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@@ -102,22 +102,6 @@ var _ = Describe("Players", func() {
Expect(repo.lastSaved).To(Equal(p))
Expect(trc.ID).To(Equal("1"))
})
Context("bad username casing", func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
ctx = request.WithUsername(ctx, "Johndoe")
It("finds player by client and user names when not ID is provided", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
})
})
})
})
@@ -141,9 +125,9 @@ func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
func (m *mockPlayerRepository) FindMatch(userName, client, typ string) (*model.Player, error) {
for _, p := range m.data {
if p.Client == client && p.UserId == userId && p.UserAgent == userAgent {
if p.Client == client && p.UserName == userName {
return &p, nil
}
}

View File

@@ -1,6 +1,8 @@
package core
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
@@ -12,19 +14,15 @@ import (
"strings"
"time"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
)
type Playlists interface {
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
}
type playlists struct {
@@ -49,26 +47,6 @@ func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: false,
}
err := s.parseM3U(ctx, pls, "", reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
if err != nil {
@@ -84,11 +62,10 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, base
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
err = s.parseNSP(ctx, pls, file)
return s.parseNSP(ctx, pls, file)
default:
err = s.parseM3U(ctx, pls, baseDir, file)
return s.parseM3U(ctx, pls, baseDir, file)
}
return pls, err
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
@@ -112,14 +89,13 @@ func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*mod
return pls, nil
}
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) error {
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
nsp := &nspFile{}
reader := jsoncommentstrip.NewReader(file)
dec := json.NewDecoder(reader)
dec := json.NewDecoder(file)
err := dec.Decode(nsp)
if err != nil {
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
return err
return nil, err
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
@@ -128,58 +104,38 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
return nil
return pls, nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) error {
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(file)
scanner.Split(scanLines)
var mfs model.MediaFiles
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
line := strings.TrimSpace(line)
if strings.HasPrefix(line, "#PLAYLIST:") {
pls.Name = line[len("#PLAYLIST:"):]
continue
}
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
line, _ = url.QueryUnescape(line)
}
if baseDir != "" && !filepath.IsAbs(line) {
line = filepath.Join(baseDir, line)
}
filteredLines = append(filteredLines, line)
}
found, err := mediaFileRepository.FindByPaths(filteredLines)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
// Skip empty lines and extended info
if path == "" || strings.HasPrefix(path, "#") {
continue
}
existing := make(map[string]int, len(found))
for idx := range found {
existing[strings.ToLower(found[idx].Path)] = idx
if strings.HasPrefix(path, "file://") {
path = strings.TrimPrefix(path, "file://")
path, _ = url.QueryUnescape(path)
}
for _, path := range filteredLines {
idx, ok := existing[strings.ToLower(path)]
if ok {
mfs = append(mfs, found[idx])
} else {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
}
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
}
}
if pls.Name == "" {
pls.Name = time.Now().Format(time.RFC3339)
mf, err := mediaFileRepository.FindByPath(path)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
continue
}
mfs = append(mfs, *mf)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)
return nil
return pls, scanner.Err()
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
@@ -201,15 +157,38 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = &time.Time{}
newPls.EvaluatedAt = time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return s.ds.Playlist(ctx).Put(newPls)
}
// From https://stackoverflow.com/a/41433698
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
// We have a line terminated by single newline.
return i + 1, data[0:i], nil
}
advance = i + 1
if len(data) > i+1 && data[i+1] == '\n' {
advance += 1
}
return advance, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
@@ -220,17 +199,13 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
var pls *model.Playlist
var err error
repo := tx.Playlist(ctx)
tracks := repo.Tracks(playlistID, true)
if tracks == nil {
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
}
if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistID, true)
pls.RemoveTracks(idxToRemove)
pls.AddTracks(idsToAdd)
} else {
if len(idsToAdd) > 0 {
_, err = tracks.Add(idsToAdd)
_, err = repo.Tracks(playlistID, true).Add(idsToAdd)
if err != nil {
return err
}
@@ -257,7 +232,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
}
// Special case: The playlist is now empty
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
if err = tracks.DeleteAll(); err != nil {
if err = repo.Tracks(playlistID, true).DeleteAll(); err != nil {
return err
}
}

View File

@@ -2,198 +2,66 @@ package core
import (
"context"
"os"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ds model.DataStore
var ps Playlists
var mp mockedPlaylist
ctx := context.Background()
BeforeEach(func() {
mp = mockedPlaylist{}
ds = &tests.MockDataStore{
MockedPlaylist: &mp,
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mockedPlaylist{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFile", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
})
Describe("M3U", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
})
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mp.last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
})
})
Describe("ImportM3U", func() {
var repo *mockedMediaFileFromListRepo
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-with-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(pls.Sync).To(BeFalse())
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
f.Close()
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-without-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(2))
})
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
repo.data = []string{
"test1.mp3",
"test2.mp3",
"test3.mp3",
}
m3u := strings.Join([]string{
"test3.mp3",
"test1.mp3",
"test4.mp3",
"test2.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("test3.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("test1.mp3"))
Expect(pls.Tracks[2].Path).To(Equal("test2.mp3"))
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(2))
})
It("is case-insensitive when comparing paths", func() {
repo.data = []string{
"tEsT1.Mp3",
}
m3u := strings.Join([]string{
"TeSt1.mP3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tEsT1.Mp3"))
})
})
})
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
type mockedMediaFileRepo struct {
type mockedMediaFile struct {
model.MediaFileRepository
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range paths {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
}
return mfs, nil
}
// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
type mockedMediaFileFromListRepo struct {
model.MediaFileRepository
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range r.data {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
}
return mfs, nil
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
return &model.MediaFile{
ID: "123",
Path: s,
}, nil
}
type mockedPlaylist struct {
last *model.Playlist
model.PlaylistRepository
}
@@ -201,7 +69,6 @@ func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
r.last = pls
func (r *mockedPlaylist) Put(*model.Playlist) error {
return nil
}

View File

@@ -7,14 +7,17 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/ReneKroon/ttlcache/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
)
const maxNowPlayingExpire = 60 * time.Minute
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
@@ -37,7 +40,7 @@ type PlayTracker interface {
type playTracker struct {
ds model.DataStore
broker events.Broker
playMap cache.SimpleCache[string, NowPlayingInfo]
playMap *ttlcache.Cache
scrobblers map[string]Scrobbler
}
@@ -50,7 +53,9 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
// the GetPlayTracker function above
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
m := ttlcache.NewCache()
m.SkipTTLExtensionOnHit(true)
_ = m.SetTTL(maxNowPlayingExpire)
p := &playTracker{ds: ds, playMap: m, broker: broker}
p.scrobblers = make(map[string]Scrobbler)
for name, constructor := range constructors {
@@ -80,7 +85,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
ttl := time.Duration(int(mf.Duration)+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
_ = p.playMap.SetWithTTL(playerId, info, ttl)
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf)
@@ -107,7 +112,15 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
res := p.playMap.Values()
var res []NowPlayingInfo
for _, playerId := range p.playMap.GetKeys() {
value, err := p.playMap.Get(playerId)
if err != nil {
continue
}
info := value.(NowPlayingInfo)
res = append(res, info)
}
sort.Slice(res, func(i, j int) bool {
return res[i].Start.After(res[j].Start)
})
@@ -118,7 +131,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
username, _ := request.UsernameFrom(ctx)
player, _ := request.PlayerFrom(ctx)
if !player.ScrobbleEnabled {
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IP, "user", username)
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IPAddress, "user", username)
}
event := &events.RefreshResource{}
success := 0
@@ -150,15 +163,15 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
return p.ds.WithTx(func(tx model.DataStore) error {
err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
err := p.ds.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
if err != nil {
return err
}
err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
err = p.ds.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
if err != nil {
return err
}
err = tx.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
err = p.ds.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
return err
})
}

View File

@@ -40,16 +40,16 @@ var _ = Describe("PlayTracker", func() {
tracker = newPlayTracker(ds, events.GetBroker())
track = model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
Artist: "Track Artist",
ArtistID: "ar-1",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
ID: "123",
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
Artist: "Track Artist",
ArtistID: "ar-1",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzTrackID: "mbz-123",
}
_ = ds.MediaFile(ctx).Put(&track)
artist = model.Artist{ID: "ar-1"}

View File

@@ -10,7 +10,6 @@ import (
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -35,11 +34,10 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if err != nil {
return nil, err
}
expiresAt := V(share.ExpiresAt)
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
share.LastVisitedAt = P(time.Now())
share.LastVisitedAt = time.Now()
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@@ -92,8 +90,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return "", err
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
if s.ExpiresAt.IsZero() {
s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour)
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
@@ -130,7 +128,7 @@ func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...stri
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
if !entity.(*model.Share).ExpiresAt.IsZero() {
cols = append(cols, "expires_at")
}
return r.Persistable.Update(id, entity, cols...)

View File

@@ -4,8 +4,6 @@ import (
"github.com/google/wire"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
)
@@ -20,6 +18,4 @@ var Set = wire.NewSet(
agents.New,
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
)

View File

@@ -1,167 +0,0 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"time"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
backupPrefix = "navidrome_backup"
backupRegexString = backupPrefix + "_(.+)\\.db"
)
var backupRegex = regexp.MustCompile(backupRegexString)
const backupSuffixLayout = "2006.01.02_15.04.05"
func backupPath(t time.Time) string {
return filepath.Join(
conf.Server.Backup.Path,
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
existingConn, err := Db().Conn(ctx)
if err != nil {
return fmt.Errorf("getting existing connection: %w", err)
}
defer existingConn.Close()
backupDb, err := sql.Open(Driver, path)
if err != nil {
return fmt.Errorf("opening backup database in '%s': %w", path, err)
}
defer backupDb.Close()
backupConn, err := backupDb.Conn(ctx)
if err != nil {
return fmt.Errorf("getting backup connection: %w", err)
}
defer backupConn.Close()
err = existingConn.Raw(func(existing any) error {
return backupConn.Raw(func(backup any) error {
var sourceOk, destOk bool
var sourceConn, destConn *sqlite3.SQLiteConn
if isBackup {
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
destConn, destOk = backup.(*sqlite3.SQLiteConn)
} else {
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
destConn, destOk = existing.(*sqlite3.SQLiteConn)
}
if !sourceOk {
return fmt.Errorf("error trying to convert source to sqlite connection")
}
if !destOk {
return fmt.Errorf("error trying to convert destination to sqlite connection")
}
backupOp, err := destConn.Backup("main", sourceConn, "main")
if err != nil {
return fmt.Errorf("error starting sqlite backup: %w", err)
}
defer backupOp.Close()
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
// This will lock out other writes that could happen at the same time
done, err := backupOp.Step(-1)
if !done {
return fmt.Errorf("backup not done with step -1")
}
if err != nil {
return fmt.Errorf("error during backup step: %w", err)
}
err = backupOp.Finish()
if err != nil {
return fmt.Errorf("error finishing backup: %w", err)
}
return nil
})
})
return err
}
func Backup(ctx context.Context) (string, error) {
destPath := backupPath(time.Now())
log.Debug(ctx, "Creating backup", "path", destPath)
err := backupOrRestore(ctx, true, destPath)
if err != nil {
return "", err
}
return destPath, nil
}
func Restore(ctx context.Context, path string) error {
log.Debug(ctx, "Restoring backup", "path", path)
return backupOrRestore(ctx, false, path)
}
func Prune(ctx context.Context) (int, error) {
files, err := os.ReadDir(conf.Server.Backup.Path)
if err != nil {
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
}
var backupTimes []time.Time
for _, file := range files {
if !file.IsDir() {
submatch := backupRegex.FindStringSubmatch(file.Name())
if len(submatch) == 2 {
timestamp, err := time.Parse(backupSuffixLayout, submatch[1])
if err == nil {
backupTimes = append(backupTimes, timestamp)
}
}
}
}
if len(backupTimes) <= conf.Server.Backup.Count {
return 0, nil
}
slices.SortFunc(backupTimes, func(a, b time.Time) int {
return b.Compare(a)
})
pruneCount := 0
var errs []error
for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] {
log.Debug(ctx, "Pruning backup", "time", timeToPrune)
path := backupPath(timeToPrune)
err = os.Remove(path)
if err != nil {
errs = append(errs, err)
} else {
pruneCount++
}
}
if len(errs) > 0 {
err = errors.Join(errs...)
log.Error(ctx, "Failed to delete one or more files", "errors", err)
}
return pruneCount, err
}

View File

@@ -1,153 +0,0 @@
package db
import (
"context"
"database/sql"
"math/rand"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func shortTime(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
}
var _ = Describe("database backups", func() {
When("there are a few backup files", func() {
var ctx context.Context
var timesShuffled []time.Time
timesDecreasingChronologically := []time.Time{
shortTime(2024, 11, 6, 5, 11),
shortTime(2024, 11, 6, 5, 8),
shortTime(2024, 11, 6, 4, 32),
shortTime(2024, 11, 6, 2, 4),
shortTime(2024, 11, 6, 1, 52),
shortTime(2024, 11, 5, 23, 0),
shortTime(2024, 11, 5, 6, 4),
shortTime(2024, 11, 4, 2, 4),
shortTime(2024, 11, 3, 8, 5),
shortTime(2024, 11, 2, 5, 24),
shortTime(2024, 11, 1, 5, 24),
shortTime(2024, 10, 31, 5, 9),
shortTime(2024, 10, 30, 5, 9),
shortTime(2024, 10, 23, 14, 3),
shortTime(2024, 10, 22, 3, 6),
shortTime(2024, 10, 11, 14, 3),
shortTime(2024, 9, 21, 19, 5),
shortTime(2024, 9, 3, 8, 5),
shortTime(2024, 7, 5, 1, 1),
shortTime(2023, 8, 2, 19, 5),
shortTime(2021, 8, 2, 19, 5),
shortTime(2020, 8, 2, 19, 5),
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = tempFolder
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
})
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
copy(timesShuffled, timesDecreasingChronologically)
rand.Shuffle(len(timesShuffled), func(i, j int) {
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
})
for _, time := range timesShuffled {
path := backupPath(time)
file, err := os.Create(path)
Expect(err).ToNot(HaveOccurred())
_ = file.Close()
}
ctx = context.Background()
})
DescribeTable("prune", func(count, expected int) {
conf.Server.Backup.Count = count
pruneCount, err := Prune(ctx)
Expect(err).ToNot(HaveOccurred())
for idx, time := range timesDecreasingChronologically {
_, err := os.Stat(backupPath(time))
shouldExist := idx < conf.Server.Backup.Count
if shouldExist {
Expect(err).ToNot(HaveOccurred())
} else {
Expect(err).To(MatchError(os.ErrNotExist))
}
}
Expect(len(timesDecreasingChronologically) - pruneCount).To(Equal(expected))
},
Entry("preserve latest 5 backups", 5, 5),
Entry("delete all files", 0, 0),
Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)),
Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically)))
})
Describe("backup and restore", Ordered, func() {
var ctx context.Context
BeforeAll(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
DeferCleanup(Init())
})
BeforeEach(func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = tempFolder
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
})
})
It("successfully backups the database", func() {
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
backup, err := sql.Open(Driver, path)
Expect(err).ToNot(HaveOccurred())
Expect(isSchemaEmpty(backup)).To(BeFalse())
})
It("successfully restores the database", func() {
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
// https://stackoverflow.com/questions/525512/drop-all-tables-command
_, err = Db().ExecContext(ctx, `
PRAGMA writable_schema = 1;
DELETE FROM sqlite_master WHERE type in ('table', 'index', 'trigger');
PRAGMA writable_schema = 0;
`)
Expect(err).ToNot(HaveOccurred())
Expect(isSchemaEmpty(Db())).To(BeTrue())
err = Restore(ctx, path)
Expect(err).ToNot(HaveOccurred())
Expect(isSchemaEmpty(Db())).To(BeFalse())
})
})
})

View File

@@ -5,34 +5,26 @@ import (
"embed"
"fmt"
"github.com/mattn/go-sqlite3"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
_ "github.com/navidrome/navidrome/db/migration"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/pressly/goose/v3"
)
var (
Dialect = "sqlite3"
Driver = Dialect + "_custom"
Path string
Driver = "sqlite3"
Path string
)
//go:embed migrations/*.sql
//go:embed migration/*.sql
var embedMigrations embed.FS
const migrationsFolder = "migrations"
const migrationsFolder = "migration"
func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
},
})
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
@@ -47,15 +39,12 @@ func Db() *sql.DB {
})
}
func Close() {
func Close() error {
log.Info("Closing Database")
err := Db().Close()
if err != nil {
log.Error("Error closing Database", err)
}
return Db().Close()
}
func Init() func() {
func Init() {
db := Db()
// Disable foreign_keys to allow re-creating tables in migrations
@@ -71,46 +60,17 @@ func Init() func() {
}
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
goose.SetLogger(gooseLogger)
goose.SetBaseFS(embedMigrations)
err = goose.SetDialect(Dialect)
err = goose.SetDialect(Driver)
if err != nil {
log.Fatal("Invalid DB driver", "driver", Driver, err)
}
if !isSchemaEmpty(db) && hasPendingMigrations(db, migrationsFolder) {
log.Info("Upgrading DB Schema to latest version")
}
goose.SetLogger(gooseLogger)
err = goose.Up(db, migrationsFolder)
if err != nil {
log.Fatal("Failed to apply new migrations", err)
}
return Close
}
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) {
if len(v) < 1 {
return
}
if v0, ok := v[0].(string); !ok {
return
} else if v0 == "Pending" {
l.numPending++
}
}
func hasPendingMigrations(db *sql.DB, folder string) bool {
l := &statusLogger{}
goose.SetLogger(l)
err := goose.Status(db, folder)
if err != nil {
log.Fatal("Failed to check for pending migrations", err)
}
return l.numPending > 0
}
func isSchemaEmpty(db *sql.DB) bool {

View File

@@ -21,7 +21,7 @@ var _ = Describe("isSchemaEmpty", func() {
var db *sql.DB
BeforeEach(func() {
path := "file::memory:"
db, _ = sql.Open(Dialect, path)
db, _ = sql.Open(Driver, path)
})
It("returns false if the goose metadata table is found", func() {

View File

@@ -1,7 +1,6 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/log"
@@ -9,10 +8,10 @@ import (
)
func init() {
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
goose.AddMigration(Up20200130083147, Down20200130083147)
}
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
func Up20200130083147(tx *sql.Tx) error {
log.Info("Creating DB Schema")
_, err := tx.Exec(`
create table if not exists album
@@ -179,6 +178,6 @@ create table if not exists user
return err
}
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
func Down20200130083147(tx *sql.Tx) error {
return nil
}

View File

@@ -1,17 +1,16 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
goose.AddMigration(Up20200131183653, Down20200131183653)
}
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
func Up20200131183653(tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(
@@ -37,7 +36,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
return err
}
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
func Down20200131183653(tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(

View File

@@ -1,17 +1,16 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
goose.AddMigration(Up20200208222418, Down20200208222418)
}
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
func Up20200208222418(tx *sql.Tx) error {
_, err := tx.Exec(`
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
@@ -51,6 +50,6 @@ create index annotation_starred
return err
}
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
func Down20200208222418(tx *sql.Tx) error {
return nil
}

View File

@@ -1,17 +1,16 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
goose.AddMigration(Up20200220143731, Down20200220143731)
}
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
func Up20200220143731(tx *sql.Tx) error {
notice(tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
create table media_file_dg_tmp
@@ -125,6 +124,6 @@ update media_file set updated_at = '0001-01-01';
return err
}
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
func Down20200220143731(tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show More