mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-06 05:48:09 -05:00
Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b18489327 | ||
|
|
8880f67035 | ||
|
|
99dfb832eb | ||
|
|
51c16aa69f | ||
|
|
972229d1e8 | ||
|
|
c8f174ea84 | ||
|
|
d4dc8180a2 | ||
|
|
851f54ea57 | ||
|
|
72a0f59be3 | ||
|
|
c11fd9ce28 | ||
|
|
3e47819f7a | ||
|
|
906ac635c2 | ||
|
|
6bc4c0317f | ||
|
|
04f296cc73 | ||
|
|
21dd04cb7d | ||
|
|
2d8507cfd7 | ||
|
|
6c11649b06 | ||
|
|
4f8cd5307c | ||
|
|
32afe9698c | ||
|
|
3e7c4b6f70 | ||
|
|
dcc84e29d9 | ||
|
|
0d520dea2d | ||
|
|
2bb918f8a1 | ||
|
|
8e2052ff95 | ||
|
|
bc3576e092 | ||
|
|
44bc70b269 | ||
|
|
297f72ff1a | ||
|
|
181c29613f | ||
|
|
1a36f06147 | ||
|
|
9cbdb20a31 | ||
|
|
7f030b0859 | ||
|
|
177a1f853f | ||
|
|
627417dae3 | ||
|
|
2b0bfbd75a | ||
|
|
8fb09e71b6 | ||
|
|
c94def801e | ||
|
|
cbf5e3d51b | ||
|
|
1c0ebb9460 | ||
|
|
94bc1a1d41 | ||
|
|
9ae898d071 | ||
|
|
054946dc42 | ||
|
|
ccce1c0f6d | ||
|
|
81edef925c | ||
|
|
2d4f483812 | ||
|
|
d229ff39e5 | ||
|
|
3982ba7258 | ||
|
|
1bf94531fd | ||
|
|
6c38dc234f | ||
|
|
c1adf407a1 | ||
|
|
c952dc343a | ||
|
|
3671598121 | ||
|
|
cd0cf7c12b | ||
|
|
6c6223f2f9 | ||
|
|
6ff7ab52f4 | ||
|
|
faed2ea8d7 | ||
|
|
cf69df877a | ||
|
|
075a7e2640 | ||
|
|
3fda7445b0 | ||
|
|
fcb5e1b806 | ||
|
|
154e13f7c9 | ||
|
|
69e2a6d620 | ||
|
|
15b2dc6b48 | ||
|
|
1c48a55759 | ||
|
|
a9b301dfc5 | ||
|
|
b86a69567d | ||
|
|
67474b776c | ||
|
|
9d8c49750e | ||
|
|
a557f37834 | ||
|
|
0a650de357 | ||
|
|
9c3b456165 | ||
|
|
23bebe4e06 | ||
|
|
8808eaddda | ||
|
|
bbb3182bc9 | ||
|
|
82633d7490 | ||
|
|
28668782c6 | ||
|
|
97c06aba1a | ||
|
|
9c46e2b262 | ||
|
|
3713032f57 | ||
|
|
a358d107aa | ||
|
|
5f6a90e5aa | ||
|
|
0232afd98d | ||
|
|
270ae3549d | ||
|
|
8b5af67647 | ||
|
|
00c6a0ed1f | ||
|
|
ff79ac4336 | ||
|
|
8d37781a47 | ||
|
|
a6fb7fd705 | ||
|
|
fd81039f1b | ||
|
|
bc4aa55de3 | ||
|
|
6ec6ac1595 | ||
|
|
06e38a8024 | ||
|
|
6e5eea980d | ||
|
|
943b456d3f | ||
|
|
16d1314a68 | ||
|
|
ae6499b941 | ||
|
|
214287e00d | ||
|
|
af1add4312 | ||
|
|
b14c790641 | ||
|
|
d9fa19dab3 | ||
|
|
0281d06b01 | ||
|
|
de04393b47 | ||
|
|
55730514ea | ||
|
|
768160b05e | ||
|
|
b7285b28cf | ||
|
|
eab6aadc0f | ||
|
|
640a734896 | ||
|
|
a9334b7787 | ||
|
|
5e8085bf3c | ||
|
|
b2eb533082 | ||
|
|
936af2d895 | ||
|
|
b1c18a428b | ||
|
|
1fac9cc3ee | ||
|
|
92a1f19271 | ||
|
|
06c9c1e64a | ||
|
|
ed3ab5385d | ||
|
|
fcdd30ba8f | ||
|
|
dd48a23f92 | ||
|
|
6040a50297 | ||
|
|
9e5849e4dc | ||
|
|
13af8ed43a | ||
|
|
825cbcbf53 | ||
|
|
5be73d404f | ||
|
|
1fa245d141 | ||
|
|
782cd26b3d | ||
|
|
10a1b5faf8 | ||
|
|
84dc10529d | ||
|
|
24d911744e | ||
|
|
6031d97c9d | ||
|
|
80acfc103f | ||
|
|
76614b8f16 | ||
|
|
d31952f469 | ||
|
|
32d2d7c15b | ||
|
|
669c8f4c49 | ||
|
|
3910e77a7a | ||
|
|
196557a41a | ||
|
|
11d96f1da4 | ||
|
|
e628aafa4b | ||
|
|
ecf934feab | ||
|
|
5b89bf747f | ||
|
|
7a6845fa5a | ||
|
|
b6433057e9 | ||
|
|
d0784b6a21 | ||
|
|
b0e7941abe | ||
|
|
a02cfbe2a7 | ||
|
|
04603a1ea2 | ||
|
|
50870d3e61 | ||
|
|
27780683aa | ||
|
|
5baf0b80aa | ||
|
|
46be041e7b | ||
|
|
ee2e04b832 | ||
|
|
1ba390a72a | ||
|
|
8134edb5d1 |
@@ -1,10 +1,18 @@
|
||||
.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
|
||||
23
.github/actions/download-taglib/action.yml
vendored
Normal file
23
.github/actions/download-taglib/action.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: 'Download TagLib'
|
||||
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version of TagLib to download'
|
||||
required: true
|
||||
platform:
|
||||
description: 'Platform to download TagLib for'
|
||||
default: 'linux-amd64'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Download TagLib
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/taglib
|
||||
cd /tmp
|
||||
FILE=taglib-${{ inputs.platform }}.tar.gz
|
||||
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
|
||||
tar -xzf ${FILE} -C taglib
|
||||
PKG_CONFIG_PREFIX=/tmp/taglib
|
||||
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
|
||||
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
|
||||
84
.github/actions/prepare-docker/action.yml
vendored
Normal file
84
.github/actions/prepare-docker/action.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
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}}
|
||||
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
@@ -10,3 +10,13 @@ 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
|
||||
40
.github/workflows/pipeline.dockerfile
vendored
40
.github/workflows/pipeline.dockerfile
vendored
@@ -1,40 +0,0 @@
|
||||
#####################################################
|
||||
### 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:3.18
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
|
||||
# Install ffmpeg and mpv
|
||||
RUN apk add -U --no-cache ffmpeg mpv
|
||||
|
||||
# Show ffmpeg build info, for troubleshooting purposes
|
||||
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"]
|
||||
430
.github/workflows/pipeline.yml
vendored
430
.github/workflows/pipeline.yml
vendored
@@ -9,29 +9,74 @@ on:
|
||||
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"
|
||||
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="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
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
with:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
- name: Install goimports
|
||||
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
||||
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
|
||||
- name: Run go goimports
|
||||
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'`
|
||||
- run: go mod tidy
|
||||
- name: Verify no changes from goimports and go mod tidy
|
||||
run: |
|
||||
@@ -44,25 +89,25 @@ jobs:
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
with:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- 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
|
||||
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
|
||||
run: go test -shuffle=on -race -cover ./... -v
|
||||
run: |
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -tags netgo -race -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Build JS bundle
|
||||
name: Test JS code
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
@@ -94,11 +139,6 @@ jobs:
|
||||
cd ui
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
retention-days: 7
|
||||
i18n-lint:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
@@ -116,108 +156,272 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
binaries:
|
||||
name: Build binaries
|
||||
needs: [js, go, go-lint, i18n-lint]
|
||||
check-push-enabled:
|
||||
name: Check Docker configuration
|
||||
runs-on: ubuntu-latest
|
||||
container: deluan/ci-goreleaser:1.23.0-1
|
||||
outputs:
|
||||
is_enabled: ${{ steps.check.outputs.is_enabled }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check if Docker push is configured
|
||||
id: check
|
||||
run: echo "is_enabled=${{ secrets.DOCKER_HUB_USERNAME != '' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Config workspace folder as trusted
|
||||
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: js-bundle
|
||||
path: ui/build
|
||||
|
||||
- name: Run GoReleaser - SNAPSHOT
|
||||
if: startsWith(github.ref, 'refs/tags/') != true
|
||||
run: goreleaser release --clean --skip=publish --snapshot
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser - RELEASE
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: goreleaser release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binaries
|
||||
path: |
|
||||
dist
|
||||
!dist/*.tar.gz
|
||||
!dist/*.zip
|
||||
retention-days: 7
|
||||
|
||||
docker:
|
||||
name: Build and publish Docker images
|
||||
needs: [binaries]
|
||||
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:
|
||||
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
|
||||
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: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
- name: Sanitize platform name
|
||||
id: set-platform
|
||||
run: |
|
||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
- name: Prepare Docker Buildx
|
||||
uses: ./.github/actions/prepare-docker
|
||||
id: docker
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.DOCKER_IMAGE != ''
|
||||
uses: docker/login-action@v3
|
||||
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@v5
|
||||
with:
|
||||
labels: |
|
||||
maintainer=deluan
|
||||
images: |
|
||||
name=${{secrets.DOCKER_IMAGE}}
|
||||
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@v5
|
||||
- name: Build Binaries
|
||||
uses: docker/build-push-action@v6
|
||||
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 }}
|
||||
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
|
||||
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
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
merge-multiple: true
|
||||
|
||||
- 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 }}"
|
||||
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*
|
||||
|
||||
- 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
|
||||
|
||||
upload-packages:
|
||||
name: Upload Linux PKG
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release]
|
||||
strategy:
|
||||
matrix:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
# 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
|
||||
9
.github/workflows/update-translations.sh
vendored
9
.github/workflows/update-translations.sh
vendored
@@ -42,3 +42,12 @@ for file in ${I18N_DIR}/*.json; do
|
||||
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
|
||||
13
.github/workflows/update-translations.yml
vendored
13
.github/workflows/update-translations.yml
vendored
@@ -10,19 +10,24 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get updated translations
|
||||
id: poeditor
|
||||
env:
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
run: |
|
||||
.github/workflows/update-translations.sh
|
||||
.github/workflows/update-translations.sh 2> title.tmp
|
||||
title=$(cat title.tmp)
|
||||
echo "::set-output name=title::$title"
|
||||
rm title.tmp
|
||||
- name: Show changes, if any
|
||||
run: |
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: Update translations
|
||||
title: Update translations from POEditor
|
||||
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"
|
||||
branch: update-translations
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -11,18 +11,17 @@ wiki
|
||||
TODO.md
|
||||
var
|
||||
navidrome.toml
|
||||
!release/linux/navidrome.toml
|
||||
master.zip
|
||||
testDB
|
||||
navidrome.db
|
||||
cache/*
|
||||
*.swp
|
||||
embedded_gen.go
|
||||
dist
|
||||
music
|
||||
navidrome.db-shm
|
||||
navidrome.db-wal
|
||||
tags
|
||||
*.db*
|
||||
.gitinfo
|
||||
docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
test-123.db
|
||||
binaries
|
||||
taglib
|
||||
navidrome-master
|
||||
@@ -1,3 +1,7 @@
|
||||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
|
||||
134
.goreleaser.yml
134
.goreleaser.yml
@@ -1,134 +0,0 @@
|
||||
# GoReleaser config
|
||||
project_name: navidrome
|
||||
version: 2
|
||||
|
||||
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
|
||||
|
||||
checksum:
|
||||
name_template: "{{ .ProjectName }}_checksums.txt"
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ .Tag }}-SNAPSHOT"
|
||||
|
||||
release:
|
||||
draft: true
|
||||
|
||||
changelog:
|
||||
# sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
@@ -48,14 +48,15 @@ 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. **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
|
||||
3. **sec**: Fixing security issues
|
||||
4. **docs**: Documentation Changes
|
||||
5. **style**: Changes to styling
|
||||
6. **refactor**: Refactoring of code
|
||||
7. **perf**: Code that affects performance
|
||||
8. **test**: Updating or improving the current tests
|
||||
9. **build**: Changes to Build process
|
||||
10. **revert**: Reverting to a previous commit
|
||||
11. **chore** : updating grunt tasks etc
|
||||
|
||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||
|
||||
|
||||
144
Dockerfile
Normal file
144
Dockerfile
Normal file
@@ -0,0 +1,144 @@
|
||||
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"]
|
||||
|
||||
97
Makefile
97
Makefile
@@ -3,13 +3,19 @@ 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`)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||
else
|
||||
GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||
endif
|
||||
|
||||
CI_RELEASER_VERSION ?= 1.23.0-1 ## https://github.com/navidrome/ci-goreleaser
|
||||
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/*")
|
||||
|
||||
@@ -19,23 +25,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
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
|
||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" 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 -notify ./...
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -tags netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: ##@Development Run Go tests
|
||||
go test -race -shuffle=on ./...
|
||||
go test -tags netgo -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: test ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm test -- --watchAll=false)
|
||||
@(cd ./ui && npm run test:ci)
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
@@ -54,7 +60,7 @@ format: ##@Development Format code
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go run github.com/google/wire/cmd/wire@latest ./...
|
||||
go run github.com/google/wire/cmd/wire@latest gen -tags=netgo ./...
|
||||
.PHONY: wire
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
@@ -81,45 +87,67 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||
.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)-SNAPSHOT" -tags=netgo
|
||||
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
|
||||
.PHONY: buildall
|
||||
|
||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
|
||||
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
|
||||
|
||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||
.PHONY: buildjs
|
||||
|
||||
docker-buildjs: ##@Build Build only frontend using Docker
|
||||
docker build --output "./ui" --target ui-bundle .
|
||||
.PHONY: docker-buildjs
|
||||
|
||||
ui/build/index.html: $(UI_SRC_FILES)
|
||||
@(cd ./ui && npm run build)
|
||||
|
||||
all: buildjs ##@Cross_Compilation Build binaries for all supported platforms.
|
||||
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
|
||||
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser release --clean --skip=publish --snapshot
|
||||
.PHONY: all
|
||||
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
|
||||
|
||||
single: buildjs ##@Cross_Compilation Build binaries for a single supported platforms.
|
||||
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
|
||||
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
|
||||
echo "Options:"; \
|
||||
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Building binaries for ${GOOS}/${GOARCH} using builder ${CI_RELEASER_VERSION}"
|
||||
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
|
||||
goreleaser build --clean --snapshot -p 2 --single-target --id navidrome_${GOOS}_${GOARCH}
|
||||
.PHONY: single
|
||||
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: buildjs ##@Build Build Docker linux/amd64 image (tagged as `deluan/navidrome:develop`)
|
||||
GOOS=linux GOARCH=amd64 make single
|
||||
@echo "Building Docker image"
|
||||
docker build . --platform linux/amd64 -t deluan/navidrome:develop -f .github/workflows/pipeline.dockerfile
|
||||
.PHONY: docker
|
||||
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
|
||||
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
@@ -136,6 +164,11 @@ 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
|
||||
|
||||
12
README.md
12
README.md
@@ -7,8 +7,9 @@
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](CODE_OF_CONDUCT.md)
|
||||
[](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!
|
||||
@@ -56,6 +57,15 @@ 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:
|
||||
|
||||
186
cmd/backup.go
Normal file
186
cmd/backup.go
Normal file
@@ -0,0 +1,186 @@
|
||||
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-path")
|
||||
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("Restore 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 backing up database", "backup path", conf.Server.BasePath, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Info("Restore complete", "elapsed", elapsed)
|
||||
}
|
||||
74
cmd/root.go
74
cmd/root.go
@@ -2,7 +2,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -12,7 +11,7 @@ import (
|
||||
"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/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
@@ -37,7 +36,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome()
|
||||
runNavidrome(cmd.Context())
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
@@ -50,8 +49,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||
func Execute() {
|
||||
rootCmd.SetVersionTemplate(`{{println .Version}}`)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +67,10 @@ func postRun() {
|
||||
// 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() {
|
||||
func runNavidrome(ctx context.Context) {
|
||||
defer db.Init()()
|
||||
|
||||
ctx, cancel := mainContext()
|
||||
ctx, cancel := mainContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
@@ -81,6 +79,8 @@ func runNavidrome() {
|
||||
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 {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
@@ -88,8 +88,8 @@ func runNavidrome() {
|
||||
}
|
||||
|
||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||
func mainContext() (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(context.Background(),
|
||||
func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(ctx,
|
||||
os.Interrupt,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGTERM,
|
||||
@@ -112,14 +112,14 @@ func startServer(ctx context.Context) func() error {
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
// blocking call because takes <1ms but useful if fails
|
||||
core.WriteInitialMetrics()
|
||||
metrics.WriteInitialMetrics()
|
||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
|
||||
}
|
||||
if conf.Server.DevEnableProfiler {
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
@@ -155,6 +155,41 @@ 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 {
|
||||
return func() error {
|
||||
@@ -165,6 +200,21 @@ func startScheduler(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
time.Sleep(conf.Server.DevInsightsInitialDelay)
|
||||
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 {
|
||||
@@ -191,11 +241,13 @@ func init() {
|
||||
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().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")
|
||||
|
||||
267
cmd/svc.go
Normal file
267
cmd/svc.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
svcStatusLabels = map[service.Status]string{
|
||||
service.StatusUnknown: "Unknown",
|
||||
service.StatusStopped: "Stopped",
|
||||
service.StatusRunning: "Running",
|
||||
}
|
||||
|
||||
installUser string
|
||||
workingDirectory string
|
||||
)
|
||||
|
||||
func init() {
|
||||
svcCmd.AddCommand(buildInstallCmd())
|
||||
svcCmd.AddCommand(buildUninstallCmd())
|
||||
svcCmd.AddCommand(buildStartCmd())
|
||||
svcCmd.AddCommand(buildStopCmd())
|
||||
svcCmd.AddCommand(buildStatusCmd())
|
||||
svcCmd.AddCommand(buildExecuteCmd())
|
||||
rootCmd.AddCommand(svcCmd)
|
||||
}
|
||||
|
||||
var svcCmd = &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc"},
|
||||
Short: "Manage Navidrome as a service",
|
||||
Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()),
|
||||
Run: runServiceCmd,
|
||||
}
|
||||
|
||||
type svcControl struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (p *svcControl) Start(service.Service) error {
|
||||
p.done = make(chan struct{})
|
||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||
go func() {
|
||||
runNavidrome(p.ctx)
|
||||
close(p.done)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
prg := &svcControl{}
|
||||
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)
|
||||
}
|
||||
return filepath.Dir(ex)
|
||||
}
|
||||
|
||||
func buildInstallCmd() *cobra.Command {
|
||||
runInstallCmd := func(_ *cobra.Command, _ []string) {
|
||||
var err error
|
||||
println("Installing service with:")
|
||||
println(" working directory: " + executablePath())
|
||||
println(" music folder: " + conf.Server.MusicFolder)
|
||||
println(" data folder: " + conf.Server.DataFolder)
|
||||
if 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 {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println(" config file: " + conf.Server.ConfigFile)
|
||||
}
|
||||
err = svcInstance().Install()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service installed. Use 'navidrome svc start' to start it.")
|
||||
}
|
||||
|
||||
cmd := &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 {
|
||||
return &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Uninstall Navidrome service. Does not delete the music or data folders",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := svcInstance().Uninstall()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service uninstalled. Music and data folders are still intact.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildStartCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start Navidrome service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := svcInstance().Start()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service started. Use 'navidrome svc status' to check its status.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildStopCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop Navidrome service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := svcInstance().Stop()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
println("Service stopped. Use 'navidrome svc status' to check its status.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show Navidrome service status",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
status, err := svcInstance().Status()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
`
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
@@ -29,25 +30,27 @@ import (
|
||||
// Injectors from wire_injectors.go:
|
||||
|
||||
func CreateServer(musicFolder string) *server.Server {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
serverServer := server.New(dataStore, broker)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
serverServer := server.New(dataStore, broker, insights)
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
@@ -69,8 +72,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.New(dataStore)
|
||||
@@ -85,22 +88,29 @@ func CreatePublicRouter() *public.Router {
|
||||
}
|
||||
|
||||
func CreateLastFMRouter() *lastfm.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := lastfm.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := listenbrainz.NewRouter(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
return insights
|
||||
}
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -114,8 +124,8 @@ func GetScanner() scanner.Scanner {
|
||||
}
|
||||
|
||||
func GetPlaybackServer() playback.PlaybackServer {
|
||||
dbDB := db.Db()
|
||||
dataStore := persistence.New(dbDB)
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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"
|
||||
@@ -70,6 +71,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func GetScanner() scanner.Scanner {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
||||
4
conf/buildtags/buildtags.go
Normal file
4
conf/buildtags/buildtags.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package buildtags
|
||||
|
||||
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||
// required build tags are disabled.
|
||||
11
conf/buildtags/netgo.go
Normal file
11
conf/buildtags/netgo.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//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
|
||||
@@ -26,6 +26,7 @@ type configOptions struct {
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
LogFile string
|
||||
ScanInterval time.Duration
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
@@ -41,6 +42,7 @@ type configOptions struct {
|
||||
EnableTranscodingConfig bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableInsightsCollector bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
@@ -87,6 +89,7 @@ type configOptions struct {
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
Backup backupOptions
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
@@ -100,6 +103,7 @@ type configOptions struct {
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
DevActivityPanel bool
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevEnableBufferedScrobble bool
|
||||
DevShowArtistPage bool
|
||||
@@ -109,6 +113,8 @@ type configOptions struct {
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
@@ -152,6 +158,12 @@ type jukeboxOptions struct {
|
||||
AdminOnly bool
|
||||
}
|
||||
|
||||
type backupOptions struct {
|
||||
Count int
|
||||
Path string
|
||||
Schedule string
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -168,14 +180,17 @@ func LoadFromFile(confFile string) {
|
||||
}
|
||||
|
||||
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:", "path", Server.DataFolder, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -184,7 +199,7 @@ func Load() {
|
||||
}
|
||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", "path", Server.CacheFolder, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -193,6 +208,24 @@ func Load() {
|
||||
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)
|
||||
@@ -202,10 +235,14 @@ 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.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error())
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.BasePath = u.Path
|
||||
@@ -221,7 +258,7 @@ func Load() {
|
||||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
}
|
||||
_, _ = fmt.Fprintln(os.Stderr, prettyConf)
|
||||
_, _ = fmt.Fprintln(out, prettyConf)
|
||||
}
|
||||
|
||||
if !Server.EnableExternalServices {
|
||||
@@ -234,8 +271,34 @@ 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
|
||||
@@ -263,15 +326,35 @@ func validateScanSchedule() error {
|
||||
Server.ScanSchedule = ""
|
||||
return nil
|
||||
}
|
||||
if _, err := time.ParseDuration(Server.ScanSchedule); err == nil {
|
||||
Server.ScanSchedule = "@every " + Server.ScanSchedule
|
||||
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
|
||||
}
|
||||
c := cron.New()
|
||||
_, err := c.AddFunc(Server.ScanSchedule, func() {})
|
||||
id, err := c.AddFunc(schedule, func() {})
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
return err
|
||||
return schedule, err
|
||||
}
|
||||
|
||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||
@@ -284,6 +367,7 @@ func init() {
|
||||
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")
|
||||
@@ -332,6 +416,7 @@ 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)
|
||||
@@ -364,12 +449,17 @@ func init() {
|
||||
|
||||
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)
|
||||
@@ -382,6 +472,8 @@ func init() {
|
||||
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) {
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package consts
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
const (
|
||||
AppName = "navidrome"
|
||||
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_cache_size=1000000000&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on&_txlock=immediate"
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
@@ -57,7 +58,7 @@ const (
|
||||
SkipScanFile = ".ndignore"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "placeholder.png"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
@@ -86,6 +87,13 @@ const (
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
)
|
||||
|
||||
const (
|
||||
InsightsIDKey = "InsightsID"
|
||||
InsightsEndpoint = "https://insights.navidrome.org/collect"
|
||||
InsightsUpdateInterval = 24 * time.Hour
|
||||
InsightsInitialDelay = 30 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []struct {
|
||||
@@ -127,3 +135,11 @@ 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
|
||||
}()
|
||||
|
||||
@@ -11,15 +11,13 @@ WantedBy=multi-user.target
|
||||
User=navidrome
|
||||
Group=navidrome
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/navidrome
|
||||
ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml"
|
||||
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
|
||||
|
||||
@@ -3,11 +3,13 @@ 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"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -178,13 +181,51 @@ 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.Warn(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||
return l.callAlbumGetInfo(ctx, name, artist, "")
|
||||
}
|
||||
|
||||
@@ -205,7 +246,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.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
log.Debug(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
return l.callArtistGetInfo(ctx, name, "")
|
||||
}
|
||||
|
||||
@@ -221,7 +262,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.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
|
||||
log.Debug(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 {
|
||||
@@ -236,7 +277,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.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
|
||||
log.Debug(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 {
|
||||
|
||||
@@ -65,7 +65,9 @@ func (s *Router) routes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{}
|
||||
resp := map[string]interface{}{
|
||||
"apiKey": s.apiKey,
|
||||
}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
|
||||
@@ -130,7 +130,7 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, false)
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
}
|
||||
|
||||
@@ -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(a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
|
||||
ff = append(ff, fromTag(ctx, 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 != "":
|
||||
|
||||
@@ -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(a.mediafile.Path),
|
||||
fromTag(ctx, a.mediafile.Path),
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, a.mediafile.Path),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,9 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
|
||||
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(r, a.size, a.square)
|
||||
resized, origSize, err := resizeImage(orig, a.size, a.square)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
|
||||
} else {
|
||||
@@ -75,9 +71,9 @@ 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 {
|
||||
// Force finish reading any remaining data
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
return io.NopCloser(buf), "", nil //nolint:nilerr
|
||||
// if we couldn't resize the image, return the original
|
||||
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
|
||||
return orig, "", err
|
||||
}
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -79,7 +80,14 @@ func fromExternalFile(ctx context.Context, files string, pattern string) sourceF
|
||||
}
|
||||
}
|
||||
|
||||
func fromTag(path string) sourceFunc {
|
||||
// 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 {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
@@ -95,10 +103,31 @@ func fromTag(path string) sourceFunc {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
types := m.PictureTypes()
|
||||
if len(types) == 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -112,13 +141,7 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer r.Close()
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = io.Copy(buf, r)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return io.NopCloser(buf), path, nil
|
||||
return r, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -13,24 +15,33 @@ import (
|
||||
"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(context.TODO()).Get(consts.JWTSecretKey)
|
||||
|
||||
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
|
||||
if err != nil || secret == "" {
|
||||
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
|
||||
secret = uuid.NewString()
|
||||
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 = []byte(secret)
|
||||
TokenAuth = jwtauth.New("HS256", Secret, nil)
|
||||
|
||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,3 +123,25 @@ 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[:]
|
||||
}
|
||||
|
||||
@@ -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,8 +32,10 @@ var _ = BeforeSuite(func() {
|
||||
var _ = Describe("Auth", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
auth.Secret = []byte(testJWTSecret)
|
||||
auth.TokenAuth = jwtauth.New("HS256", auth.Secret, nil)
|
||||
ds := &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
auth.Init(ds)
|
||||
})
|
||||
|
||||
Describe("Validate", func() {
|
||||
|
||||
@@ -64,11 +64,11 @@ func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMeta
|
||||
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 nil, err
|
||||
return auxAlbum{}, err
|
||||
}
|
||||
|
||||
var album auxAlbum
|
||||
@@ -79,9 +79,9 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum,
|
||||
case *model.MediaFile:
|
||||
return e.getAlbum(ctx, v.AlbumID)
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
return auxAlbum{}, model.ErrNotFound
|
||||
}
|
||||
return &album, nil
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
@@ -94,7 +94,7 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
|
||||
err = e.populateAlbumInfo(ctx, album)
|
||||
album, err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -103,22 +103,22 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
// If info is expired, trigger a populateAlbumInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
|
||||
e.albumQueue.enqueue(*album)
|
||||
e.albumQueue.enqueue(&album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbum) error {
|
||||
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
start := time.Now()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return nil
|
||||
return album, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
|
||||
"elapsed", time.Since(start), err)
|
||||
return err
|
||||
return album, err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
@@ -152,14 +152,14 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbu
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
return nil
|
||||
return album, 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 nil, err
|
||||
return auxArtist{}, err
|
||||
}
|
||||
|
||||
var artist auxArtist
|
||||
@@ -172,9 +172,9 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
|
||||
case *model.Album:
|
||||
return e.getArtist(ctx, v.AlbumArtistID)
|
||||
default:
|
||||
return nil, model.ErrNotFound
|
||||
return auxArtist{}, model.ErrNotFound
|
||||
}
|
||||
return &artist, nil
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
@@ -183,35 +183,35 @@ 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 nil, err
|
||||
return auxArtist{}, 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)
|
||||
err := e.populateArtistInfo(ctx, artist)
|
||||
artist, err = e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return auxArtist{}, 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)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxArtist) error {
|
||||
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
@@ -224,15 +224,15 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
|
||||
// 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 ctx.Err()
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
@@ -243,7 +243,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
|
||||
} else {
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
||||
}
|
||||
return nil
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
@@ -252,7 +252,7 @@ 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()
|
||||
@@ -310,7 +310,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()
|
||||
@@ -552,28 +552,31 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
|
||||
return nil
|
||||
}
|
||||
|
||||
type refreshQueue[T any] chan<- T
|
||||
type refreshQueue[T any] chan<- *T
|
||||
|
||||
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, *T) error) refreshQueue[T] {
|
||||
queue := make(chan T, refreshQueueLength)
|
||||
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) (T, error)) refreshQueue[T] {
|
||||
queue := make(chan *T, refreshQueueLength)
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(refreshDelay)
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case item := <-queue:
|
||||
_ = processFn(ctx, &item)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
break
|
||||
return
|
||||
case <-time.After(refreshDelay):
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case item := <-queue:
|
||||
_, _ = processFn(ctx, *item)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return queue
|
||||
}
|
||||
|
||||
func (q *refreshQueue[T]) enqueue(item T) {
|
||||
func (q *refreshQueue[T]) enqueue(item *T) {
|
||||
select {
|
||||
case *q <- item:
|
||||
default: // It is ok to miss a refresh request
|
||||
|
||||
@@ -18,8 +18,6 @@ import (
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
@@ -33,8 +31,6 @@ func New() FFmpeg {
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
createWavCmd = "ffmpeg -i %s -c:a pcm_s16le -f wav -"
|
||||
createFLACCmd = "ffmpeg -i %s -f flac -"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
@@ -55,16 +51,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(createWavCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
args := createFFmpegCommand(createFLACCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return "", err
|
||||
@@ -153,31 +139,26 @@ func (j *ffCmd) wait() {
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
var parts []string
|
||||
|
||||
for _, s := range split {
|
||||
var args []string
|
||||
for _, s := range fixCmd(cmd) {
|
||||
if strings.Contains(s, "%s") {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
parts = append(parts, s)
|
||||
args = append(args, s)
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
parts = append(parts, "-ss", strconv.Itoa(offset))
|
||||
args = append(args, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
parts = append(parts, s)
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
return args
|
||||
}
|
||||
|
||||
func createProbeCommand(cmd string, inputs []string) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
var args []string
|
||||
|
||||
for _, s := range split {
|
||||
for _, s := range fixCmd(cmd) {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
args = append(args, "-i", inp)
|
||||
@@ -189,18 +170,15 @@ func createProbeCommand(cmd string, inputs []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func fixCmd(cmd string) string {
|
||||
split := strings.Split(cmd, " ")
|
||||
var result []string
|
||||
func fixCmd(cmd string) []string {
|
||||
split := strings.Fields(cmd)
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
for _, s := range split {
|
||||
for i, s := range split {
|
||||
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
||||
result = append(result, cmdPath)
|
||||
} else {
|
||||
result = append(result, s)
|
||||
split[i] = cmdPath
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
return split
|
||||
}
|
||||
|
||||
func ffmpegCmd() (string, error) {
|
||||
@@ -223,6 +201,7 @@ 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
|
||||
|
||||
@@ -27,6 +27,10 @@ var _ = Describe("ffmpeg", 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", "-"}))
|
||||
})
|
||||
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)
|
||||
@@ -48,4 +52,17 @@ 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"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -128,64 +127,56 @@ func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// selectTranscodingOptions selects the appropriate transcoding options based on the requested format and bitrate.
|
||||
// If the requested format is "raw" or matches the media file's suffix and the requested bitrate is 0, it returns the
|
||||
// original format and bitrate.
|
||||
// Otherwise, it determines the format and bitrate using determineFormatAndBitRate and findTranscoding functions.
|
||||
//
|
||||
// NOTE: It is easier to follow the tests in core/media_streamer_internal_test.go to understand the different scenarios.
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (string, int) {
|
||||
if reqFormat == "raw" || reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
return "raw", mf.BitRate
|
||||
// TODO This function deserves some love (refactoring)
|
||||
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
|
||||
}
|
||||
|
||||
format, bitRate := determineFormatAndBitRate(ctx, mf.BitRate, reqFormat, reqBitRate)
|
||||
if format == "" && bitRate == 0 {
|
||||
return "raw", 0
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return format, bitRate
|
||||
}
|
||||
|
||||
return findTranscoding(ctx, ds, mf, format, bitRate)
|
||||
}
|
||||
|
||||
// determineFormatAndBitRate determines the format and bitrate for transcoding based on the requested format and bitrate.
|
||||
// If the requested format is not empty, it returns the requested format and bitrate.
|
||||
// Otherwise, it checks for default transcoding settings from the context or server configuration.
|
||||
func determineFormatAndBitRate(ctx context.Context, srcBitRate int, reqFormat string, reqBitRate int) (string, int) {
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
return reqFormat, reqBitRate
|
||||
}
|
||||
|
||||
format, bitRate := "", 0
|
||||
if trc, hasDefault := request.TranscodingFrom(ctx); hasDefault {
|
||||
format = trc.TargetFormat
|
||||
bitRate = trc.DefaultBitRate
|
||||
|
||||
if p, ok := request.PlayerFrom(ctx); ok && p.MaxBitRate > 0 && p.MaxBitRate < bitRate {
|
||||
bitRate = p.MaxBitRate
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
|
||||
cFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < srcBitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug(ctx, "Using default downsampling format", "format", conf.Server.DefaultDownsamplingFormat)
|
||||
format = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
return format, cmp.Or(reqBitRate, bitRate)
|
||||
}
|
||||
|
||||
// findTranscoding finds the appropriate transcoding settings for the given format and bitrate.
|
||||
// If the format matches the media file's suffix and the bitrate is greater than or equal to the original bitrate,
|
||||
// it returns the original format and bitrate.
|
||||
// Otherwise, it returns the target format and bitrate from the
|
||||
// transcoding settings.
|
||||
func findTranscoding(ctx context.Context, ds model.DataStore, mf *model.MediaFile, format string, bitRate int) (string, int) {
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil || t == nil || format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
return "raw", 0
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
|
||||
return t.TargetFormat, cmp.Or(bitRate, t.DefaultBitRate)
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return format, bitRate
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return format, bitRate
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -122,10 +122,11 @@ 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: 80}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
ctx = request.WithPlayer(ctx, p)
|
||||
})
|
||||
@@ -140,7 +141,7 @@ var _ = Describe("MediaStreamer", func() {
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
@@ -152,9 +153,9 @@ var _ = Describe("MediaStreamer", func() {
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
Expect(bitRate).To(Equal(160))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
262
core/metrics/insights.go
Normal file
262
core/metrics/insights.go
Normal file
@@ -0,0 +1,262 @@
|
||||
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
|
||||
}
|
||||
73
core/metrics/insights/data.go
Normal file
73
core/metrics/insights/data.go
Normal file
@@ -0,0 +1,73 @@
|
||||
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"`
|
||||
}
|
||||
37
core/metrics/insights_darwin.go
Normal file
37
core/metrics/insights_darwin.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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
|
||||
}
|
||||
9
core/metrics/insights_default.go
Normal file
9
core/metrics/insights_default.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !windows && !darwin
|
||||
|
||||
package metrics
|
||||
|
||||
import "errors"
|
||||
|
||||
func getOSVersion() (string, string) { return "", "" }
|
||||
|
||||
func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") }
|
||||
84
core/metrics/insights_linux.go
Normal file
84
core/metrics/insights_linux.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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",
|
||||
0x28cd3d45: "cramfs",
|
||||
0x64626720: "debugfs",
|
||||
0x0000EF53: "ext2/ext3/ext4",
|
||||
0x6a656a63: "fakeowner", // FS inside a container
|
||||
0x65735546: "fuse",
|
||||
0x4244: "hfs",
|
||||
0x9660: "iso9660",
|
||||
0x3153464a: "jfs",
|
||||
0x00006969: "nfs",
|
||||
0x794c7630: "overlayfs",
|
||||
0x9fa0: "proc",
|
||||
0x517b: "smb",
|
||||
0xfe534d42: "smb2",
|
||||
0x73717368: "squashfs",
|
||||
0x62656572: "sysfs",
|
||||
0x01021994: "tmpfs",
|
||||
0x01021997: "v9fs",
|
||||
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
|
||||
}
|
||||
53
core/metrics/insights_windows.go
Normal file
53
core/metrics/insights_windows.go
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,8 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -20,6 +18,7 @@ import (
|
||||
"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 {
|
||||
@@ -55,9 +54,9 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: true,
|
||||
Sync: false,
|
||||
}
|
||||
pls, err := s.parseM3U(ctx, pls, "", reader)
|
||||
err := s.parseM3U(ctx, pls, "", reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
@@ -85,10 +84,11 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, base
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
return s.parseNSP(ctx, pls, file)
|
||||
err = s.parseNSP(ctx, pls, file)
|
||||
default:
|
||||
return s.parseM3U(ctx, pls, baseDir, file)
|
||||
err = s.parseM3U(ctx, pls, baseDir, file)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
@@ -112,14 +112,14 @@ 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) (*model.Playlist, error) {
|
||||
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader := jsoncommentstrip.NewReader(file)
|
||||
dec := json.NewDecoder(reader)
|
||||
err := dec.Decode(nsp)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
@@ -128,39 +128,50 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
return pls, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Split(scanLines)
|
||||
var mfs model.MediaFiles
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "#PLAYLIST:") {
|
||||
if split := strings.Split(line, ":"); len(split) >= 2 {
|
||||
pls.Name = split[1]
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
mf, err := mediaFileRepository.FindByPath(line)
|
||||
found, err := mediaFileRepository.FindByPaths(filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
mfs = append(mfs, *mf)
|
||||
existing := make(map[string]int, len(found))
|
||||
for idx := range found {
|
||||
existing[strings.ToLower(found[idx].Path)] = idx
|
||||
}
|
||||
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 pls.Name == "" {
|
||||
pls.Name = time.Now().Format(time.RFC3339)
|
||||
@@ -168,7 +179,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir s
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFiles(mfs)
|
||||
|
||||
return pls, scanner.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
@@ -199,30 +210,6 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
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 {
|
||||
|
||||
@@ -3,19 +3,20 @@ 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/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds model.DataStore
|
||||
var ds *tests.MockDataStore
|
||||
var ps Playlists
|
||||
var mp mockedPlaylist
|
||||
ctx := context.Background()
|
||||
@@ -23,8 +24,7 @@ var _ = Describe("Playlists", func() {
|
||||
BeforeEach(func() {
|
||||
mp = mockedPlaylist{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: &mockedMediaFile{},
|
||||
MockedPlaylist: &mp,
|
||||
MockedPlaylist: &mp,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -32,12 +32,13 @@ var _ = Describe("Playlists", func() {
|
||||
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).To(BeNil())
|
||||
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"))
|
||||
@@ -48,13 +49,13 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
|
||||
Expect(err).To(BeNil())
|
||||
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).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
@@ -62,7 +63,7 @@ var _ = Describe("Playlists", func() {
|
||||
Describe("NSP", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mp.last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
@@ -76,18 +77,28 @@ var _ = Describe("Playlists", func() {
|
||||
})
|
||||
|
||||
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() {
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u")
|
||||
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(err).To(BeNil())
|
||||
Expect(pls.Sync).To(BeFalse())
|
||||
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"))
|
||||
@@ -97,25 +108,88 @@ var _ = Describe("Playlists", func() {
|
||||
})
|
||||
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u")
|
||||
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).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = time.Parse(time.RFC3339, pls.Name)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(3))
|
||||
})
|
||||
|
||||
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("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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockedMediaFile struct {
|
||||
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
|
||||
type mockedMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
||||
return &model.MediaFile{
|
||||
ID: "123",
|
||||
Path: s,
|
||||
}, nil
|
||||
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
|
||||
}
|
||||
|
||||
type mockedPlaylist struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,4 +21,5 @@ var Set = wire.NewSet(
|
||||
ffmpeg.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
metrics.GetInstance,
|
||||
)
|
||||
|
||||
165
db/backup.go
Normal file
165
db/backup.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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/
|
||||
backupDb, err := sql.Open(Driver, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer backupDb.Close()
|
||||
|
||||
existingConn, err := Db().Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer existingConn.Close()
|
||||
|
||||
backupConn, err := backupDb.Conn(ctx)
|
||||
if err != nil {
|
||||
return 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())
|
||||
err := backupOrRestore(ctx, true, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
func Restore(ctx context.Context, path string) error {
|
||||
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
|
||||
}
|
||||
153
db/backup_test.go
Normal file
153
db/backup_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
69
db/db.go
69
db/db.go
@@ -4,7 +4,6 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -16,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Driver = "sqlite3"
|
||||
Path string
|
||||
Dialect = "sqlite3"
|
||||
Driver = Dialect + "_custom"
|
||||
Path string
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
@@ -25,37 +25,9 @@ var embedMigrations embed.FS
|
||||
|
||||
const migrationsFolder = "migrations"
|
||||
|
||||
type DB interface {
|
||||
ReadDB() *sql.DB
|
||||
WriteDB() *sql.DB
|
||||
Close()
|
||||
}
|
||||
|
||||
type db struct {
|
||||
readDB *sql.DB
|
||||
writeDB *sql.DB
|
||||
}
|
||||
|
||||
func (d *db) ReadDB() *sql.DB {
|
||||
return d.readDB
|
||||
}
|
||||
|
||||
func (d *db) WriteDB() *sql.DB {
|
||||
return d.writeDB
|
||||
}
|
||||
|
||||
func (d *db) Close() {
|
||||
if err := d.readDB.Close(); err != nil {
|
||||
log.Error("Error closing read DB", err)
|
||||
}
|
||||
if err := d.writeDB.Close(); err != nil {
|
||||
log.Error("Error closing write DB", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Db() DB {
|
||||
return singleton.GetInstance(func() *db {
|
||||
sql.Register(Driver+"_custom", &sqlite3.SQLiteDriver{
|
||||
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)
|
||||
},
|
||||
@@ -67,35 +39,24 @@ func Db() DB {
|
||||
conf.Server.DbPath = Path
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
|
||||
// Create a read database connection
|
||||
rdb, err := sql.Open(Driver+"_custom", Path)
|
||||
instance, err := sql.Open(Driver, Path)
|
||||
if err != nil {
|
||||
log.Fatal("Error opening read database", err)
|
||||
}
|
||||
rdb.SetMaxOpenConns(max(4, runtime.NumCPU()))
|
||||
|
||||
// Create a write database connection
|
||||
wdb, err := sql.Open(Driver+"_custom", Path)
|
||||
if err != nil {
|
||||
log.Fatal("Error opening write database", err)
|
||||
}
|
||||
wdb.SetMaxOpenConns(1)
|
||||
|
||||
return &db{
|
||||
readDB: rdb,
|
||||
writeDB: wdb,
|
||||
panic(err)
|
||||
}
|
||||
return instance
|
||||
})
|
||||
}
|
||||
|
||||
func Close() {
|
||||
log.Info("Closing Database")
|
||||
Db().Close()
|
||||
err := Db().Close()
|
||||
if err != nil {
|
||||
log.Error("Error closing Database", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Init() func() {
|
||||
db := Db().WriteDB()
|
||||
db := Db()
|
||||
|
||||
// Disable foreign_keys to allow re-creating tables in migrations
|
||||
_, err := db.Exec("PRAGMA foreign_keys=off")
|
||||
@@ -112,7 +73,7 @@ func Init() func() {
|
||||
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
|
||||
goose.SetBaseFS(embedMigrations)
|
||||
|
||||
err = goose.SetDialect(Driver)
|
||||
err = goose.SetDialect(Dialect)
|
||||
if err != nil {
|
||||
log.Fatal("Invalid DB driver", "driver", Driver, err)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("isSchemaEmpty", func() {
|
||||
var db *sql.DB
|
||||
BeforeEach(func() {
|
||||
path := "file::memory:"
|
||||
db, _ = sql.Open(Driver, path)
|
||||
db, _ = sql.Open(Dialect, path)
|
||||
})
|
||||
|
||||
It("returns false if the goose metadata table is found", func() {
|
||||
|
||||
9
db/migrations/20241020003138_add_sort_tags_index.sql
Normal file
9
db/migrations/20241020003138_add_sort_tags_index.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
create index if not exists media_file_sort_title on media_file(coalesce(nullif(sort_title,''),order_title));
|
||||
create index if not exists album_sort_name on album(coalesce(nullif(sort_album_name,''),order_album_name));
|
||||
create index if not exists artist_sort_name on artist(coalesce(nullif(sort_artist_name,''),order_artist_name));
|
||||
|
||||
-- +goose Down
|
||||
drop index if exists media_file_sort_title;
|
||||
drop index if exists album_sort_name;
|
||||
drop index if exists artist_sort_name;
|
||||
@@ -0,0 +1,512 @@
|
||||
-- +goose Up
|
||||
--region Artist Table
|
||||
create table artist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
album_count integer default 0 not null,
|
||||
full_text varchar(255) default '',
|
||||
song_count integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
biography varchar(255) default '' not null,
|
||||
small_image_url varchar(255) default '' not null,
|
||||
medium_image_url varchar(255) default '' not null,
|
||||
large_image_url varchar(255) default '' not null,
|
||||
similar_artists varchar(255) default '' not null,
|
||||
external_url varchar(255) default '' not null,
|
||||
external_info_updated_at datetime,
|
||||
order_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_artist_name varchar collate NOCASE default '' not null,
|
||||
mbz_artist_id varchar default '' not null
|
||||
);
|
||||
|
||||
insert into artist_dg_tmp(id, name, album_count, full_text, song_count, size, biography, small_image_url,
|
||||
medium_image_url, large_image_url, similar_artists, external_url, external_info_updated_at,
|
||||
order_artist_name, sort_artist_name, mbz_artist_id)
|
||||
select id,
|
||||
name,
|
||||
album_count,
|
||||
full_text,
|
||||
song_count,
|
||||
size,
|
||||
biography,
|
||||
small_image_url,
|
||||
medium_image_url,
|
||||
large_image_url,
|
||||
similar_artists,
|
||||
external_url,
|
||||
external_info_updated_at,
|
||||
order_artist_name,
|
||||
sort_artist_name,
|
||||
mbz_artist_id
|
||||
from artist;
|
||||
|
||||
drop table artist;
|
||||
|
||||
alter table artist_dg_tmp
|
||||
rename to artist;
|
||||
|
||||
create index artist_full_text
|
||||
on artist (full_text);
|
||||
|
||||
create index artist_name
|
||||
on artist (name);
|
||||
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
create index artist_size
|
||||
on artist (size);
|
||||
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
--endregion
|
||||
|
||||
--region Album Table
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
embed_art_path varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
min_year int default 0 not null,
|
||||
max_year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration real default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
full_text varchar(255) default '',
|
||||
album_artist_id varchar(255) default '',
|
||||
size integer default 0 not null,
|
||||
all_artist_ids varchar,
|
||||
description varchar(255) default '' not null,
|
||||
small_image_url varchar(255) default '' not null,
|
||||
medium_image_url varchar(255) default '' not null,
|
||||
large_image_url varchar(255) default '' not null,
|
||||
external_url varchar(255) default '' not null,
|
||||
external_info_updated_at datetime,
|
||||
date varchar(255) default '' not null,
|
||||
min_original_year int default 0 not null,
|
||||
max_original_year int default 0 not null,
|
||||
original_date varchar(255) default '' not null,
|
||||
release_date varchar(255) default '' not null,
|
||||
releases integer default 0 not null,
|
||||
image_files varchar default '' not null,
|
||||
order_album_name varchar collate NOCASE default '' not null,
|
||||
order_album_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_album_name varchar collate NOCASE default '' not null,
|
||||
sort_album_artist_name varchar collate NOCASE default '' not null,
|
||||
catalog_num varchar default '' not null,
|
||||
comment varchar default '' not null,
|
||||
paths varchar default '' not null,
|
||||
mbz_album_id varchar default '' not null,
|
||||
mbz_album_artist_id varchar default '' not null,
|
||||
mbz_album_type varchar default '' not null,
|
||||
mbz_album_comment varchar default '' not null,
|
||||
discs jsonb default '{}' not null,
|
||||
library_id integer default 1 not null
|
||||
references library
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
insert into album_dg_tmp(id, name, artist_id, embed_art_path, artist, album_artist, min_year, max_year, compilation,
|
||||
song_count, duration, genre, created_at, updated_at, full_text, album_artist_id, size,
|
||||
all_artist_ids, description, small_image_url, medium_image_url, large_image_url, external_url,
|
||||
external_info_updated_at, date, min_original_year, max_original_year, original_date,
|
||||
release_date, releases, image_files, order_album_name, order_album_artist_name,
|
||||
sort_album_name, sort_album_artist_name, catalog_num, comment, paths,
|
||||
mbz_album_id, mbz_album_artist_id, mbz_album_type, mbz_album_comment, discs, library_id)
|
||||
select id,
|
||||
name,
|
||||
artist_id,
|
||||
embed_art_path,
|
||||
artist,
|
||||
album_artist,
|
||||
min_year,
|
||||
max_year,
|
||||
compilation,
|
||||
song_count,
|
||||
duration,
|
||||
genre,
|
||||
created_at,
|
||||
updated_at,
|
||||
full_text,
|
||||
album_artist_id,
|
||||
size,
|
||||
all_artist_ids,
|
||||
description,
|
||||
small_image_url,
|
||||
medium_image_url,
|
||||
large_image_url,
|
||||
external_url,
|
||||
external_info_updated_at,
|
||||
date,
|
||||
min_original_year,
|
||||
max_original_year,
|
||||
original_date,
|
||||
release_date,
|
||||
releases,
|
||||
image_files,
|
||||
order_album_name,
|
||||
order_album_artist_name,
|
||||
sort_album_name,
|
||||
sort_album_artist_name,
|
||||
catalog_num,
|
||||
comment,
|
||||
paths,
|
||||
mbz_album_id,
|
||||
mbz_album_artist_id,
|
||||
mbz_album_type,
|
||||
mbz_album_comment,
|
||||
discs,
|
||||
library_id
|
||||
from album;
|
||||
|
||||
drop table album;
|
||||
|
||||
alter table album_dg_tmp
|
||||
rename to album;
|
||||
|
||||
create index album_all_artist_ids
|
||||
on album (all_artist_ids);
|
||||
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name, order_album_name);
|
||||
|
||||
create index album_artist
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album_id
|
||||
on album (album_artist_id);
|
||||
|
||||
create index album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index album_created_at
|
||||
on album (created_at);
|
||||
|
||||
create index album_full_text
|
||||
on album (full_text);
|
||||
|
||||
create index album_genre
|
||||
on album (genre);
|
||||
|
||||
create index album_max_year
|
||||
on album (max_year);
|
||||
|
||||
create index album_mbz_album_type
|
||||
on album (mbz_album_type);
|
||||
|
||||
create index album_min_year
|
||||
on album (min_year);
|
||||
|
||||
create index album_name
|
||||
on album (name);
|
||||
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
create index album_order_album_name
|
||||
on album (order_album_name);
|
||||
|
||||
create index album_size
|
||||
on album (size);
|
||||
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
|
||||
|
||||
create index album_updated_at
|
||||
on album (updated_at);
|
||||
--endregion
|
||||
|
||||
--region Media File Table
|
||||
create table media_file_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
path varchar(255) default '' not null,
|
||||
title varchar(255) default '' not null,
|
||||
album varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
album_id varchar(255) default '' not null,
|
||||
has_cover_art bool default FALSE not null,
|
||||
track_number integer default 0 not null,
|
||||
disc_number integer default 0 not null,
|
||||
year integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
suffix varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
bit_rate integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
compilation bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
full_text varchar(255) default '',
|
||||
album_artist_id varchar(255) default '',
|
||||
date varchar(255) default '' not null,
|
||||
original_year int default 0 not null,
|
||||
original_date varchar(255) default '' not null,
|
||||
release_year int default 0 not null,
|
||||
release_date varchar(255) default '' not null,
|
||||
order_album_name varchar collate NOCASE default '' not null,
|
||||
order_album_artist_name varchar collate NOCASE default '' not null,
|
||||
order_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_album_name varchar collate NOCASE default '' not null,
|
||||
sort_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_album_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_title varchar collate NOCASE default '' not null,
|
||||
disc_subtitle varchar default '' not null,
|
||||
catalog_num varchar default '' not null,
|
||||
comment varchar default '' not null,
|
||||
order_title varchar collate NOCASE default '' not null,
|
||||
mbz_recording_id varchar default '' not null,
|
||||
mbz_album_id varchar default '' not null,
|
||||
mbz_artist_id varchar default '' not null,
|
||||
mbz_album_artist_id varchar default '' not null,
|
||||
mbz_album_type varchar default '' not null,
|
||||
mbz_album_comment varchar default '' not null,
|
||||
mbz_release_track_id varchar default '' not null,
|
||||
bpm integer default 0 not null,
|
||||
channels integer default 0 not null,
|
||||
rg_album_gain real default 0 not null,
|
||||
rg_album_peak real default 0 not null,
|
||||
rg_track_gain real default 0 not null,
|
||||
rg_track_peak real default 0 not null,
|
||||
lyrics jsonb default '[]' not null,
|
||||
sample_rate integer default 0 not null,
|
||||
library_id integer default 1 not null
|
||||
references library
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art,
|
||||
track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation,
|
||||
created_at, updated_at, full_text, album_artist_id, date, original_year, original_date,
|
||||
release_year, release_date, order_album_name, order_album_artist_name, order_artist_name,
|
||||
sort_album_name, sort_artist_name, sort_album_artist_name, sort_title, disc_subtitle,
|
||||
catalog_num, comment, order_title, mbz_recording_id, mbz_album_id, mbz_artist_id,
|
||||
mbz_album_artist_id, mbz_album_type, mbz_album_comment, mbz_release_track_id, bpm,
|
||||
channels, rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak, lyrics, sample_rate,
|
||||
library_id)
|
||||
select id,
|
||||
path,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
artist_id,
|
||||
album_artist,
|
||||
album_id,
|
||||
has_cover_art,
|
||||
track_number,
|
||||
disc_number,
|
||||
year,
|
||||
size,
|
||||
suffix,
|
||||
duration,
|
||||
bit_rate,
|
||||
genre,
|
||||
compilation,
|
||||
created_at,
|
||||
updated_at,
|
||||
full_text,
|
||||
album_artist_id,
|
||||
date,
|
||||
original_year,
|
||||
original_date,
|
||||
release_year,
|
||||
release_date,
|
||||
order_album_name,
|
||||
order_album_artist_name,
|
||||
order_artist_name,
|
||||
sort_album_name,
|
||||
sort_artist_name,
|
||||
sort_album_artist_name,
|
||||
sort_title,
|
||||
disc_subtitle,
|
||||
catalog_num,
|
||||
comment,
|
||||
order_title,
|
||||
mbz_recording_id,
|
||||
mbz_album_id,
|
||||
mbz_artist_id,
|
||||
mbz_album_artist_id,
|
||||
mbz_album_type,
|
||||
mbz_album_comment,
|
||||
mbz_release_track_id,
|
||||
bpm,
|
||||
channels,
|
||||
rg_album_gain,
|
||||
rg_album_peak,
|
||||
rg_track_gain,
|
||||
rg_track_peak,
|
||||
lyrics,
|
||||
sample_rate,
|
||||
library_id
|
||||
from media_file;
|
||||
|
||||
drop table media_file;
|
||||
|
||||
alter table media_file_dg_tmp
|
||||
rename to media_file;
|
||||
|
||||
create index media_file_album_artist
|
||||
on media_file (album_artist);
|
||||
|
||||
create index media_file_album_id
|
||||
on media_file (album_id);
|
||||
|
||||
create index media_file_artist
|
||||
on media_file (artist);
|
||||
|
||||
create index media_file_artist_album_id
|
||||
on media_file (album_artist_id);
|
||||
|
||||
create index media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
|
||||
create index media_file_bpm
|
||||
on media_file (bpm);
|
||||
|
||||
create index media_file_channels
|
||||
on media_file (channels);
|
||||
|
||||
create index media_file_created_at
|
||||
on media_file (created_at);
|
||||
|
||||
create index media_file_duration
|
||||
on media_file (duration);
|
||||
|
||||
create index media_file_full_text
|
||||
on media_file (full_text);
|
||||
|
||||
create index media_file_genre
|
||||
on media_file (genre);
|
||||
|
||||
create index media_file_mbz_track_id
|
||||
on media_file (mbz_recording_id);
|
||||
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
|
||||
create index media_file_order_title
|
||||
on media_file (order_title);
|
||||
|
||||
create index media_file_path
|
||||
on media_file (path);
|
||||
|
||||
create index media_file_path_nocase
|
||||
on media_file (path collate NOCASE);
|
||||
|
||||
create index media_file_sample_rate
|
||||
on media_file (sample_rate);
|
||||
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
create index media_file_title
|
||||
on media_file (title);
|
||||
|
||||
create index media_file_track_number
|
||||
on media_file (disc_number, track_number);
|
||||
|
||||
create index media_file_updated_at
|
||||
on media_file (updated_at);
|
||||
|
||||
create index media_file_year
|
||||
on media_file (year);
|
||||
|
||||
--endregion
|
||||
|
||||
--region Radio Table
|
||||
create table radio_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar collate NOCASE not null
|
||||
unique,
|
||||
stream_url varchar not null,
|
||||
home_page_url varchar default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into radio_dg_tmp(id, name, stream_url, home_page_url, created_at, updated_at)
|
||||
select id, name, stream_url, home_page_url, created_at, updated_at
|
||||
from radio;
|
||||
|
||||
drop table radio;
|
||||
|
||||
alter table radio_dg_tmp
|
||||
rename to radio;
|
||||
|
||||
create index radio_name
|
||||
on radio(name);
|
||||
--endregion
|
||||
|
||||
--region users Table
|
||||
create table user_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
user_name varchar(255) default '' not null
|
||||
unique,
|
||||
name varchar(255) collate NOCASE default '' not null,
|
||||
email varchar(255) default '' not null,
|
||||
password varchar(255) default '' not null,
|
||||
is_admin bool default FALSE not null,
|
||||
last_login_at datetime,
|
||||
last_access_at datetime,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null
|
||||
);
|
||||
|
||||
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at,
|
||||
updated_at)
|
||||
select id,
|
||||
user_name,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
last_login_at,
|
||||
last_access_at,
|
||||
created_at,
|
||||
updated_at
|
||||
from user;
|
||||
|
||||
drop table user;
|
||||
|
||||
alter table user_dg_tmp
|
||||
rename to user;
|
||||
|
||||
create index user_username_password
|
||||
on user(user_name collate NOCASE, password);
|
||||
--endregion
|
||||
|
||||
-- +goose Down
|
||||
alter table album
|
||||
add column sort_artist_name varchar default '' not null;
|
||||
54
go.mod
54
go.mod
@@ -1,14 +1,18 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.23
|
||||
go 1.23.3
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
github.com/andybalholm/cascadia v1.3.2
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||
github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/djherbis/atime v1.1.0
|
||||
@@ -17,38 +21,42 @@ require (
|
||||
github.com/djherbis/times v1.6.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.14.1
|
||||
github.com/go-chi/jwtauth/v5 v5.3.1
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.6.0
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.3
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mattn/go-zglob v0.0.6
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.20.2
|
||||
github.com/onsi/gomega v1.34.2
|
||||
github.com/onsi/ginkgo/v2 v2.22.0
|
||||
github.com/onsi/gomega v1.36.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/pocketbase/dbx v1.10.1
|
||||
github.com/pressly/goose/v3 v3.22.0
|
||||
github.com/prometheus/client_golang v1.20.3
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.23.1
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/unrolled/secure v1.15.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
|
||||
golang.org/x/image v0.20.0
|
||||
golang.org/x/sync v0.8.0
|
||||
golang.org/x/text v0.18.0
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.32.0
|
||||
golang.org/x/sync v0.10.0
|
||||
golang.org/x/sys v0.28.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/time v0.8.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -63,7 +71,7 @@ require (
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
@@ -97,11 +105,9 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/tools v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
110
go.sum
110
go.sum
@@ -4,6 +4,8 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -22,12 +24,12 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnN
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI=
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37 h1:s+qNFsO3VsdsKroqapcogQxcQBHrRPDK1nVxGc+HBbg=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37/go.mod h1:CXCwawNJCtFDip7gvbaQVgw0cGjldpyHDIp7oA5prOg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||
github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA=
|
||||
github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
|
||||
@@ -46,14 +48,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
|
||||
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
@@ -67,8 +69,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -94,6 +96,8 @@ github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHT
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
|
||||
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -115,8 +119,8 @@ github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCG
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI32E=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
@@ -125,8 +129,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
|
||||
github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
@@ -141,22 +145,22 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4=
|
||||
github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag=
|
||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
|
||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
|
||||
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.22.0 h1:wd/7kNiPTuNAztWun7iaB98DrhulbWPrzMAaw2DEZNw=
|
||||
github.com/pressly/goose/v3 v3.22.0/go.mod h1:yJM3qwSj2pp7aAaCvso096sguezamNb2OBgxCnh/EYg=
|
||||
github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
|
||||
github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.23.1 h1:bwjOXvep4HtuiiIqtrXmCkQu0IW9O9JAqA6UQNY9ntk=
|
||||
github.com/pressly/goose/v3 v3.23.1/go.mod h1:0oK0zcK7cmNqJSVwMIOiUUW0ox2nDIz+UfPMSOaw2zY=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
@@ -207,12 +211,12 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
|
||||
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -224,13 +228,13 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
|
||||
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
|
||||
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -241,20 +245,22 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -262,14 +268,16 @@ golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
@@ -281,8 +289,10 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
@@ -290,12 +300,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
|
||||
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -315,8 +325,8 @@ modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
|
||||
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -22,3 +25,37 @@ func ShortDur(d time.Duration) string {
|
||||
s = strings.TrimSuffix(s, "0s")
|
||||
return strings.TrimSuffix(s, "0m")
|
||||
}
|
||||
|
||||
func StringerValue(s fmt.Stringer) string {
|
||||
v := reflect.ValueOf(s)
|
||||
if v.Kind() == reflect.Pointer && v.IsNil() {
|
||||
return "nil"
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func CRLFWriter(w io.Writer) io.Writer {
|
||||
return &crlfWriter{w: w}
|
||||
}
|
||||
|
||||
type crlfWriter struct {
|
||||
w io.Writer
|
||||
lastByte byte
|
||||
}
|
||||
|
||||
func (cw *crlfWriter) Write(p []byte) (int, error) {
|
||||
var written int
|
||||
for _, b := range p {
|
||||
if b == '\n' && cw.lastByte != '\r' {
|
||||
if _, err := cw.w.Write([]byte{'\r'}); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
if _, err := cw.w.Write([]byte{b}); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written++
|
||||
cw.lastByte = b
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package log
|
||||
package log_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = DescribeTable("ShortDur",
|
||||
func(d time.Duration, expected string) {
|
||||
Expect(ShortDur(d)).To(Equal(expected))
|
||||
Expect(log.ShortDur(d)).To(Equal(expected))
|
||||
},
|
||||
Entry("1ns", 1*time.Nanosecond, "1ns"),
|
||||
Entry("9µs", 9*time.Microsecond, "9µs"),
|
||||
@@ -24,3 +27,44 @@ var _ = DescribeTable("ShortDur",
|
||||
Entry("4h", 4*time.Hour+2*time.Second, "4h"),
|
||||
Entry("4h2m", 4*time.Hour+2*time.Minute+5*time.Second+200*time.Millisecond, "4h2m"),
|
||||
)
|
||||
|
||||
var _ = Describe("StringerValue", func() {
|
||||
It("should return the string representation of a fmt.Stringer", func() {
|
||||
Expect(log.StringerValue(time.Second)).To(Equal("1s"))
|
||||
})
|
||||
It("should return 'nil' for a nil fmt.Stringer", func() {
|
||||
v := (*time.Time)(nil)
|
||||
Expect(log.StringerValue(v)).To(Equal("nil"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("CRLFWriter", func() {
|
||||
var (
|
||||
buffer *bytes.Buffer
|
||||
writer io.Writer
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
buffer = new(bytes.Buffer)
|
||||
writer = log.CRLFWriter(buffer)
|
||||
})
|
||||
|
||||
Describe("Write", func() {
|
||||
It("should convert all LFs to CRLFs", func() {
|
||||
n, err := writer.Write([]byte("hello\nworld\nagain\n"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(n).To(Equal(18))
|
||||
Expect(buffer.String()).To(Equal("hello\r\nworld\r\nagain\r\n"))
|
||||
})
|
||||
|
||||
It("should not convert LF to CRLF if preceded by CR", func() {
|
||||
n, err := writer.Write([]byte("hello\r"))
|
||||
Expect(n).To(Equal(6))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
n, err = writer.Write([]byte("\nworld\n"))
|
||||
Expect(n).To(Equal(7))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(buffer.String()).To(Equal("hello\r\nworld\r\n"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
16
log/log.go
16
log/log.go
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -128,6 +128,13 @@ func SetRedacting(enabled bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func SetOutput(w io.Writer) {
|
||||
if runtime.GOOS == "windows" {
|
||||
w = CRLFWriter(w)
|
||||
}
|
||||
defaultLogger.SetOutput(w)
|
||||
}
|
||||
|
||||
// Redact applies redaction to a single string
|
||||
func Redact(msg string) string {
|
||||
r, _ := redacted.redact(msg)
|
||||
@@ -269,12 +276,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry
|
||||
case time.Duration:
|
||||
logger = logger.WithField(name, ShortDur(v))
|
||||
case fmt.Stringer:
|
||||
vOf := reflect.ValueOf(v)
|
||||
if vOf.Kind() == reflect.Pointer && vOf.IsNil() {
|
||||
logger = logger.WithField(name, "nil")
|
||||
} else {
|
||||
logger = logger.WithField(name, v.String())
|
||||
}
|
||||
logger = logger.WithField(name, StringerValue(v))
|
||||
default:
|
||||
logger = logger.WithField(name, v)
|
||||
}
|
||||
|
||||
8
main.go
8
main.go
@@ -4,8 +4,16 @@ import (
|
||||
_ "net/http/pprof" //nolint:gosec
|
||||
|
||||
"github.com/navidrome/navidrome/cmd"
|
||||
"github.com/navidrome/navidrome/conf/buildtags"
|
||||
)
|
||||
|
||||
//goland:noinspection GoBoolExpressions
|
||||
func main() {
|
||||
// This import is used to force the inclusion of the `netgo` tag when compiling the project.
|
||||
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
|
||||
// the `netgo` build tag when compiling the project.
|
||||
// To avoid these kind of errors, you should use `make build` to compile the project.
|
||||
_ = buildtags.NETGO
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ type Album struct {
|
||||
Discs Discs `structs:"discs" json:"discs,omitempty"`
|
||||
FullText string `structs:"full_text" json:"-"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
@@ -85,7 +84,7 @@ type Albums []Album
|
||||
// It assumes all albums have the same AlbumArtist, or else results are unpredictable.
|
||||
func (als Albums) ToAlbumArtist() Artist {
|
||||
a := Artist{AlbumCount: len(als)}
|
||||
var mbzArtistIds []string
|
||||
mbzArtistIds := make([]string, 0, len(als))
|
||||
for _, al := range als {
|
||||
a.ID = al.AlbumArtistID
|
||||
a.Name = al.AlbumArtist
|
||||
|
||||
@@ -39,11 +39,11 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
text = str.SanitizeText(text)
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
structuredLines := make([]Line, 0, len(lines)*2)
|
||||
|
||||
artist := ""
|
||||
title := ""
|
||||
var offset *int64 = nil
|
||||
structuredLines := []Line{}
|
||||
|
||||
synced := syncRegex.MatchString(text)
|
||||
priorLine := ""
|
||||
@@ -105,7 +105,7 @@ func ToLyrics(language, text string) (*Lyrics, error) {
|
||||
Value: strings.TrimSpace(priorLine),
|
||||
})
|
||||
}
|
||||
timestamps = []int64{}
|
||||
timestamps = nil
|
||||
}
|
||||
|
||||
end := 0
|
||||
|
||||
@@ -108,11 +108,9 @@ type MediaFiles []MediaFile
|
||||
|
||||
// Dirs returns a deduped list of all directories from the MediaFiles' paths
|
||||
func (mfs MediaFiles) Dirs() []string {
|
||||
var dirs []string
|
||||
for _, mf := range mfs {
|
||||
dir, _ := filepath.Split(mf.Path)
|
||||
dirs = append(dirs, filepath.Clean(dir))
|
||||
}
|
||||
dirs := slice.Map(mfs, func(m MediaFile) string {
|
||||
return filepath.Dir(m.Path)
|
||||
})
|
||||
slices.Sort(dirs)
|
||||
return slices.Compact(dirs)
|
||||
}
|
||||
@@ -121,16 +119,16 @@ func (mfs MediaFiles) Dirs() []string {
|
||||
// It assumes all mediafiles have the same Album, or else results are unpredictable.
|
||||
func (mfs MediaFiles) ToAlbum() Album {
|
||||
a := Album{SongCount: len(mfs)}
|
||||
var fullText []string
|
||||
var albumArtistIds []string
|
||||
var songArtistIds []string
|
||||
var mbzAlbumIds []string
|
||||
var comments []string
|
||||
var years []int
|
||||
var dates []string
|
||||
var originalYears []int
|
||||
var originalDates []string
|
||||
var releaseDates []string
|
||||
fullText := make([]string, 0, len(mfs))
|
||||
albumArtistIds := make([]string, 0, len(mfs))
|
||||
songArtistIds := make([]string, 0, len(mfs))
|
||||
mbzAlbumIds := make([]string, 0, len(mfs))
|
||||
comments := make([]string, 0, len(mfs))
|
||||
years := make([]int, 0, len(mfs))
|
||||
dates := make([]string, 0, len(mfs))
|
||||
originalYears := make([]int, 0, len(mfs))
|
||||
originalDates := make([]string, 0, len(mfs))
|
||||
releaseDates := make([]string, 0, len(mfs))
|
||||
for _, m := range mfs {
|
||||
// We assume these attributes are all the same for all songs on an album
|
||||
a.ID = m.AlbumID
|
||||
@@ -140,7 +138,6 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
a.AlbumArtist = m.AlbumArtist
|
||||
a.AlbumArtistID = m.AlbumArtistID
|
||||
a.SortAlbumName = m.SortAlbumName
|
||||
a.SortArtistName = m.SortArtistName
|
||||
a.SortAlbumArtistName = m.SortAlbumArtistName
|
||||
a.OrderAlbumName = m.OrderAlbumName
|
||||
a.OrderAlbumArtistName = m.OrderAlbumArtistName
|
||||
@@ -261,10 +258,10 @@ type MediaFileRepository interface {
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
Delete(id string) error
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// Queries by path to support the scanner, no Annotations or Bookmarks required in the response
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, error)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
DeleteByPath(path string) (int64, error)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -13,259 +14,319 @@ import (
|
||||
|
||||
var _ = Describe("MediaFiles", func() {
|
||||
var mfs MediaFiles
|
||||
|
||||
Context("Simple attributes", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist",
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3",
|
||||
},
|
||||
{
|
||||
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("sets the single values correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.ID).To(Equal("AlbumID"))
|
||||
Expect(album.Name).To(Equal("Album"))
|
||||
Expect(album.Artist).To(Equal("Artist"))
|
||||
Expect(album.ArtistID).To(Equal("ArtistID"))
|
||||
Expect(album.AlbumArtist).To(Equal("AlbumArtist"))
|
||||
Expect(album.AlbumArtistID).To(Equal("AlbumArtistID"))
|
||||
Expect(album.SortAlbumName).To(Equal("SortAlbumName"))
|
||||
Expect(album.SortArtistName).To(Equal("SortArtistName"))
|
||||
Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName"))
|
||||
Expect(album.OrderAlbumName).To(Equal("OrderAlbumName"))
|
||||
Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName"))
|
||||
Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID"))
|
||||
Expect(album.MbzAlbumType).To(Equal("MbzAlbumType"))
|
||||
Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment"))
|
||||
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
||||
Expect(album.Compilation).To(BeTrue())
|
||||
Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3"))
|
||||
Expect(album.Paths).To(Equal("/music1" + consts.Zwsp + "/music2"))
|
||||
})
|
||||
})
|
||||
Context("Aggregated attributes", func() {
|
||||
When("we have only one song", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
|
||||
}
|
||||
})
|
||||
It("calculates the aggregates correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Duration).To(Equal(float32(100.2)))
|
||||
Expect(album.Size).To(Equal(int64(1024)))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1985))
|
||||
Expect(album.Date).To(Equal("1985-01-02"))
|
||||
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30")))
|
||||
Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30")))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have multiple songs with different dates", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
|
||||
}
|
||||
})
|
||||
It("calculates the aggregates correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Duration).To(Equal(float32(451.0)))
|
||||
Expect(album.Size).To(Equal(int64(4072)))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1986))
|
||||
Expect(album.Date).To(BeEmpty())
|
||||
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45")))
|
||||
Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30")))
|
||||
})
|
||||
Context("MinYear", func() {
|
||||
It("returns 0 when all values are 0", func() {
|
||||
mfs = MediaFiles{{Year: 0}, {Year: 0}, {Year: 0}}
|
||||
a := mfs.ToAlbum()
|
||||
Expect(a.MinYear).To(Equal(0))
|
||||
})
|
||||
It("returns the smallest value from the list, not counting 0", func() {
|
||||
mfs = MediaFiles{{Year: 2000}, {Year: 0}, {Year: 1999}}
|
||||
a := mfs.ToAlbum()
|
||||
Expect(a.MinYear).To(Equal(1999))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("we have multiple songs with same dates", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
|
||||
}
|
||||
})
|
||||
It("sets the date field correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Date).To(Equal("1985-01-02"))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1985))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Calculated attributes", func() {
|
||||
Context("Discs", func() {
|
||||
When("we have no discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Album: "Album1"}, {Album: "Album1"}, {Album: "Album1"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("we have only one disc", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle"}))
|
||||
})
|
||||
})
|
||||
When("we have multiple discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}, {DiscNumber: 2, DiscSubtitle: "DiscSubtitle2"}, {DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle", 2: "DiscSubtitle2"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("Genres", func() {
|
||||
When("we have only one Genre", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}}}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Genre).To(Equal("Rock"))
|
||||
Expect(album.Genres).To(ConsistOf(Genre{ID: "g1", Name: "Rock"}))
|
||||
})
|
||||
})
|
||||
When("we have multiple Genres", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}}}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Genre).To(Equal("Rock"))
|
||||
Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}}))
|
||||
})
|
||||
})
|
||||
When("we have one predominant Genre", func() {
|
||||
var album Album
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Genres: Genres{{ID: "g2", Name: "Punk"}, {ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}}}}
|
||||
album = mfs.ToAlbum()
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
Expect(album.Genre).To(Equal("Punk"))
|
||||
})
|
||||
It("removes duplications from Genres", func() {
|
||||
Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}}))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Comments", func() {
|
||||
When("we have only one Comment", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(Equal("comment1"))
|
||||
})
|
||||
})
|
||||
When("we have multiple equal comments", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(Equal("comment1"))
|
||||
})
|
||||
})
|
||||
When("we have different comments", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("AllArtistIds", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{AlbumArtistID: "22", ArtistID: "11"},
|
||||
{AlbumArtistID: "22", ArtistID: "33"},
|
||||
{AlbumArtistID: "22", ArtistID: "11"},
|
||||
}
|
||||
})
|
||||
It("removes duplications", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.AllArtistIDs).To(Equal("11 22 33"))
|
||||
})
|
||||
})
|
||||
Context("FullText", func() {
|
||||
Describe("ToAlbum", func() {
|
||||
Context("Simple attributes", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist1", DiscSubtitle: "DiscSubtitle1",
|
||||
SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName1",
|
||||
ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist",
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3",
|
||||
},
|
||||
{
|
||||
Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist2", DiscSubtitle: "DiscSubtitle2",
|
||||
SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName2",
|
||||
ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID",
|
||||
SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName",
|
||||
OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName",
|
||||
MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment",
|
||||
Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3",
|
||||
},
|
||||
}
|
||||
})
|
||||
It("fills the fullText attribute correctly", func() {
|
||||
|
||||
It("sets the single values correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.FullText).To(Equal(" album1 albumartist1 artist1 artist2 discsubtitle1 discsubtitle2 sortalbumartistname1 sortalbumname1 sortartistname1 sortartistname2"))
|
||||
Expect(album.ID).To(Equal("AlbumID"))
|
||||
Expect(album.Name).To(Equal("Album"))
|
||||
Expect(album.Artist).To(Equal("Artist"))
|
||||
Expect(album.ArtistID).To(Equal("ArtistID"))
|
||||
Expect(album.AlbumArtist).To(Equal("AlbumArtist"))
|
||||
Expect(album.AlbumArtistID).To(Equal("AlbumArtistID"))
|
||||
Expect(album.SortAlbumName).To(Equal("SortAlbumName"))
|
||||
Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName"))
|
||||
Expect(album.OrderAlbumName).To(Equal("OrderAlbumName"))
|
||||
Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName"))
|
||||
Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID"))
|
||||
Expect(album.MbzAlbumType).To(Equal("MbzAlbumType"))
|
||||
Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment"))
|
||||
Expect(album.CatalogNum).To(Equal("CatalogNum"))
|
||||
Expect(album.Compilation).To(BeTrue())
|
||||
Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3"))
|
||||
Expect(album.Paths).To(Equal("/music1" + consts.Zwsp + "/music2"))
|
||||
})
|
||||
})
|
||||
Context("MbzAlbumID", func() {
|
||||
When("we have only one MbzAlbumID", func() {
|
||||
Context("Aggregated attributes", func() {
|
||||
When("we have only one song", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{MbzAlbumID: "id1"}}
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
|
||||
}
|
||||
})
|
||||
It("sets the correct MbzAlbumID", func() {
|
||||
It("calculates the aggregates correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.MbzAlbumID).To(Equal("id1"))
|
||||
Expect(album.Duration).To(Equal(float32(100.2)))
|
||||
Expect(album.Size).To(Equal(int64(1024)))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1985))
|
||||
Expect(album.Date).To(Equal("1985-01-02"))
|
||||
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30")))
|
||||
Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30")))
|
||||
})
|
||||
})
|
||||
When("we have multiple MbzAlbumID", func() {
|
||||
|
||||
When("we have multiple songs with different dates", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}}
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
|
||||
}
|
||||
})
|
||||
It("sets the correct MbzAlbumID", func() {
|
||||
It("calculates the aggregates correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.MbzAlbumID).To(Equal("id1"))
|
||||
Expect(album.Duration).To(Equal(float32(451.0)))
|
||||
Expect(album.Size).To(Equal(int64(4072)))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1986))
|
||||
Expect(album.Date).To(BeEmpty())
|
||||
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45")))
|
||||
Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30")))
|
||||
})
|
||||
Context("MinYear", func() {
|
||||
It("returns 0 when all values are 0", func() {
|
||||
mfs = MediaFiles{{Year: 0}, {Year: 0}, {Year: 0}}
|
||||
a := mfs.ToAlbum()
|
||||
Expect(a.MinYear).To(Equal(0))
|
||||
})
|
||||
It("returns the smallest value from the list, not counting 0", func() {
|
||||
mfs = MediaFiles{{Year: 2000}, {Year: 0}, {Year: 1999}}
|
||||
a := mfs.ToAlbum()
|
||||
Expect(a.MinYear).To(Equal(1999))
|
||||
})
|
||||
})
|
||||
})
|
||||
When("we have multiple songs with same dates", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
|
||||
{Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
|
||||
}
|
||||
})
|
||||
It("sets the date field correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Date).To(Equal("1985-01-02"))
|
||||
Expect(album.MinYear).To(Equal(1985))
|
||||
Expect(album.MaxYear).To(Equal(1985))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Calculated attributes", func() {
|
||||
Context("Discs", func() {
|
||||
When("we have no discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Album: "Album1"}, {Album: "Album1"}, {Album: "Album1"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
When("we have only one disc", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle"}))
|
||||
})
|
||||
})
|
||||
When("we have multiple discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}, {DiscNumber: 2, DiscSubtitle: "DiscSubtitle2"}, {DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}}
|
||||
})
|
||||
It("sets the correct Discs", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle", 2: "DiscSubtitle2"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("Genres", func() {
|
||||
When("we have only one Genre", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}}}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Genre).To(Equal("Rock"))
|
||||
Expect(album.Genres).To(ConsistOf(Genre{ID: "g1", Name: "Rock"}))
|
||||
})
|
||||
})
|
||||
When("we have multiple Genres", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}}}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Genre).To(Equal("Rock"))
|
||||
Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}}))
|
||||
})
|
||||
})
|
||||
When("we have one predominant Genre", func() {
|
||||
var album Album
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Genres: Genres{{ID: "g2", Name: "Punk"}, {ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}}}}
|
||||
album = mfs.ToAlbum()
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
Expect(album.Genre).To(Equal("Punk"))
|
||||
})
|
||||
It("removes duplications from Genres", func() {
|
||||
Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}}))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Comments", func() {
|
||||
When("we have only one Comment", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(Equal("comment1"))
|
||||
})
|
||||
})
|
||||
When("we have multiple equal comments", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Comment", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(Equal("comment1"))
|
||||
})
|
||||
})
|
||||
When("we have different comments", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}}
|
||||
})
|
||||
It("sets the correct Genre", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.Comment).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("AllArtistIds", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{AlbumArtistID: "22", ArtistID: "11"},
|
||||
{AlbumArtistID: "22", ArtistID: "33"},
|
||||
{AlbumArtistID: "22", ArtistID: "11"},
|
||||
}
|
||||
})
|
||||
It("removes duplications", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.AllArtistIDs).To(Equal("11 22 33"))
|
||||
})
|
||||
})
|
||||
Context("FullText", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist1", DiscSubtitle: "DiscSubtitle1",
|
||||
SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName1",
|
||||
},
|
||||
{
|
||||
Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist2", DiscSubtitle: "DiscSubtitle2",
|
||||
SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName2",
|
||||
},
|
||||
}
|
||||
})
|
||||
It("fills the fullText attribute correctly", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.FullText).To(Equal(" album1 albumartist1 artist1 artist2 discsubtitle1 discsubtitle2 sortalbumartistname1 sortalbumname1 sortartistname1 sortartistname2"))
|
||||
})
|
||||
})
|
||||
Context("MbzAlbumID", func() {
|
||||
When("we have only one MbzAlbumID", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{MbzAlbumID: "id1"}}
|
||||
})
|
||||
It("sets the correct MbzAlbumID", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.MbzAlbumID).To(Equal("id1"))
|
||||
})
|
||||
})
|
||||
When("we have multiple MbzAlbumID", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}}
|
||||
})
|
||||
It("sets the correct MbzAlbumID", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.MbzAlbumID).To(Equal("id1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Dirs", func() {
|
||||
var mfs MediaFiles
|
||||
|
||||
When("there are no media files", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{}
|
||||
})
|
||||
It("returns an empty list", func() {
|
||||
Expect(mfs.Dirs()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
When("there is one media file", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Path: "/music/artist/album/song.mp3"},
|
||||
}
|
||||
})
|
||||
It("returns the directory of the media file", func() {
|
||||
Expect(mfs.Dirs()).To(Equal([]string{filepath.Clean("/music/artist/album")}))
|
||||
})
|
||||
})
|
||||
|
||||
When("there are multiple media files in the same directory", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Path: "/music/artist/album/song1.mp3"},
|
||||
{Path: "/music/artist/album/song2.mp3"},
|
||||
}
|
||||
})
|
||||
It("returns a single directory", func() {
|
||||
Expect(mfs.Dirs()).To(Equal([]string{filepath.Clean("/music/artist/album")}))
|
||||
})
|
||||
})
|
||||
|
||||
When("there are multiple media files in different directories", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Path: "/music/artist2/album/song2.mp3"},
|
||||
{Path: "/music/artist1/album/song1.mp3"},
|
||||
}
|
||||
})
|
||||
It("returns all directories", func() {
|
||||
Expect(mfs.Dirs()).To(Equal([]string{filepath.Clean("/music/artist1/album"), filepath.Clean("/music/artist2/album")}))
|
||||
})
|
||||
})
|
||||
|
||||
When("there are media files with empty paths", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Path: ""},
|
||||
{Path: "/music/artist/album/song.mp3"},
|
||||
}
|
||||
})
|
||||
It("ignores the empty paths", func() {
|
||||
Expect(mfs.Dirs()).To(Equal([]string{".", filepath.Clean("/music/artist/album")}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,5 +26,7 @@ type PlayerRepository interface {
|
||||
Get(id string) (*Player, error)
|
||||
FindMatch(userId, client, userAgent string) (*Player, error)
|
||||
Put(p *Player) error
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
CountByClient(...QueryOptions) (map[string]int64, error)
|
||||
// TODO: Add CountAll method. Useful at least for metrics.
|
||||
}
|
||||
|
||||
@@ -52,4 +52,5 @@ type ShareRepository interface {
|
||||
Exists(id string) (bool, error)
|
||||
Get(id string) (*Share, error)
|
||||
GetAll(options ...QueryOptions) (Shares, error)
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
r := &albumRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "album"
|
||||
r.registerModel(&model.Album{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
@@ -66,26 +67,17 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_album_name, order_album_artist_name",
|
||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_album_name,''),order_album_name)",
|
||||
"artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"album_artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"recently_added": recentlyAddedSort(),
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_album_name asc, order_album_artist_name asc",
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"album_artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"recently_added": recentlyAddedSort(),
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -21,7 +20,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
|
||||
repo = NewAlbumRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
repo = NewAlbumRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@@ -59,20 +60,17 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist" // To be used by the idFilter below
|
||||
r.registerModel(&model.Artist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_artist_name",
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -138,11 +136,14 @@ func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
name := strings.ToLower(str.RemoveArticle(a.Name))
|
||||
func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
source := a.OrderArtistName
|
||||
if conf.Server.PreferSortTags {
|
||||
source = cmp.Or(a.SortArtistName, a.OrderArtistName)
|
||||
}
|
||||
name := strings.ToLower(source)
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
if strings.HasPrefix(name, strings.ToLower(k)) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -151,28 +152,16 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
all, err := r.GetAll(model.QueryOptions{Sort: "order_artist_name"})
|
||||
artists, err := r.GetAll(model.QueryOptions{Sort: "name"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullIdx := make(map[string]*model.ArtistIndex)
|
||||
for i := range all {
|
||||
a := all[i]
|
||||
ax := r.getIndexKey(&a)
|
||||
idx, ok := fullIdx[ax]
|
||||
if !ok {
|
||||
idx = &model.ArtistIndex{ID: ax}
|
||||
fullIdx[ax] = idx
|
||||
}
|
||||
idx.Artists = append(idx.Artists, a)
|
||||
}
|
||||
var result model.ArtistIndexes
|
||||
for _, idx := range fullIdx {
|
||||
result = append(result, *idx)
|
||||
for k, v := range slice.Group(artists, r.getIndexKey) {
|
||||
result = append(result, model.ArtistIndex{ID: k, Artists: v})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
slices.SortFunc(result, func(a, b model.ArtistIndex) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"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"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
@@ -19,7 +21,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
||||
repo = NewArtistRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
@@ -43,24 +45,147 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndexKey", func() {
|
||||
// Note: OrderArtistName should never be empty, so we don't need to test for that
|
||||
r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the OrderArtistName key is SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
It("returns the OrderArtistName key even if SortArtistName is not empty", func() {
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
})
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the SortArtistName key if it is not empty", func() {
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("F"))
|
||||
})
|
||||
It("returns the OrderArtistName key if SortArtistName is empty", func() {
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndex", func() {
|
||||
It("returns the index", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the index when SortArtistName is not empty", func() {
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "F",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the index when SortArtistName is not empty", func() {
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
122
persistence/collation_test.go
Normal file
122
persistence/collation_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// When creating migrations that change existing columns, it is easy to miss the original collation of a column.
|
||||
// These tests enforce that the required collation of the columns and indexes in the database are kept in place.
|
||||
// This is important to ensure that the database can perform fast case-insensitive searches and sorts.
|
||||
var _ = Describe("Collation", func() {
|
||||
conn := db.Db()
|
||||
DescribeTable("Column collation",
|
||||
func(table, column string) {
|
||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
||||
Entry("album.order_album_name", "album", "order_album_name"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
||||
Entry("media_file.order_title", "media_file", "order_title"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
)
|
||||
|
||||
DescribeTable("Index collation",
|
||||
func(table, column string) {
|
||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||
)
|
||||
})
|
||||
|
||||
func checkIndexUsage(conn *sql.DB, table string, column string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf(`
|
||||
explain query plan select * from %[1]s
|
||||
where %[2]s = 'test'
|
||||
order by %[2]s`, table, column))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows.Next() {
|
||||
var dummy int
|
||||
var detail string
|
||||
err = rows.Scan(&dummy, &dummy, &dummy, &detail)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ok, _ := regexp.MatchString("SEARCH.*USING INDEX", detail); ok {
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("INDEX for '%s' not used: %s", column, detail)
|
||||
}
|
||||
}
|
||||
return errors.New("no rows returned")
|
||||
}
|
||||
|
||||
func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows.Next() {
|
||||
var res string
|
||||
err = rows.Scan(&res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
re := regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*varchar`, column))
|
||||
if !re.MatchString(res) {
|
||||
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
||||
}
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
|
||||
if re.MatchString(res) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("table '%s' not found", table)
|
||||
}
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type dbxBuilder struct {
|
||||
dbx.Builder
|
||||
wdb dbx.Builder
|
||||
}
|
||||
|
||||
func NewDBXBuilder(d db.DB) *dbxBuilder {
|
||||
b := &dbxBuilder{}
|
||||
b.Builder = dbx.NewFromDB(d.ReadDB(), db.Driver)
|
||||
b.wdb = dbx.NewFromDB(d.WriteDB(), db.Driver)
|
||||
return b
|
||||
}
|
||||
|
||||
func (d *dbxBuilder) Transactional(f func(*dbx.Tx) error) (err error) {
|
||||
return d.wdb.(*dbx.DB).Transactional(f)
|
||||
}
|
||||
5
persistence/export_test.go
Normal file
5
persistence/export_test.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package persistence
|
||||
|
||||
// Definitions for testing private methods
|
||||
|
||||
var GetIndexKey = (*artistRepository).getIndexKey
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -16,7 +15,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
var repo model.GenreRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), persistence.NewDBXBuilder(db.Db()))
|
||||
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), persistence.GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("GetAll()", func() {
|
||||
|
||||
@@ -81,3 +81,13 @@ func (e existsCond) ToSql() (string, []interface{}, error) {
|
||||
}
|
||||
return sql, args, err
|
||||
}
|
||||
|
||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(order string) string {
|
||||
order = strings.ToLower(order)
|
||||
return sortOrderRegex.ReplaceAllString(order, "(coalesce(nullif(sort_$1,''),order_$1) collate nocase)")
|
||||
}
|
||||
|
||||
@@ -83,4 +83,23 @@ var _ = Describe("Helpers", func() {
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("mapSortOrder", func() {
|
||||
It("does not change the sort string if there are no order columns", func() {
|
||||
sort := "album_name asc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal(sort))
|
||||
})
|
||||
It("changes order columns to sort expression", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal("(coalesce(nullif(sort_album_name,''),order_album_name) collate nocase) asc"))
|
||||
})
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(sort_title,''),order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -24,30 +23,21 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepos
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "media_file"
|
||||
r.registerModel(&model.MediaFile{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "COALESCE(NULLIF(sort_title,''),title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"created_at": "media_file.created_at",
|
||||
"track_number": "album, release_date, disc_number, track_number",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
|
||||
"random": r.seededRandomSort(),
|
||||
"created_at": "media_file.created_at",
|
||||
"track_number": "album, release_date, disc_number, track_number",
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -113,16 +103,13 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Like{"path": path})
|
||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &res[0], nil
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -20,7 +19,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
It("gets mediafile from the DB", func() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"reflect"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -14,8 +15,8 @@ type SQLStore struct {
|
||||
db dbx.Builder
|
||||
}
|
||||
|
||||
func New(d db.DB) model.DataStore {
|
||||
return &SQLStore{db: NewDBXBuilder(d)}
|
||||
func New(conn *sql.DB) model.DataStore {
|
||||
return &SQLStore{db: dbx.NewFromDB(conn, db.Driver)}
|
||||
}
|
||||
|
||||
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
|
||||
@@ -105,18 +106,14 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
return nil
|
||||
}
|
||||
|
||||
type transactional interface {
|
||||
Transactional(f func(*dbx.Tx) error) (err error)
|
||||
}
|
||||
|
||||
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
||||
// If we are already in a transaction, just pass it down
|
||||
if conn, ok := s.db.(*dbx.Tx); ok {
|
||||
return block(&SQLStore{db: conn})
|
||||
conn, ok := s.db.(*dbx.DB)
|
||||
if !ok {
|
||||
conn = dbx.NewFromDB(db.Db(), db.Driver)
|
||||
}
|
||||
|
||||
return s.db.(transactional).Transactional(func(tx *dbx.Tx) error {
|
||||
return block(&SQLStore{db: tx})
|
||||
return conn.Transactional(func(tx *dbx.Tx) error {
|
||||
newDb := &SQLStore{db: tx}
|
||||
return block(newDb)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,7 +172,7 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
|
||||
func (s *SQLStore) getDBXBuilder() dbx.Builder {
|
||||
if s.db == nil {
|
||||
return NewDBXBuilder(db.Db())
|
||||
return dbx.NewFromDB(db.Db(), db.Driver)
|
||||
}
|
||||
return s.db
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
@@ -23,7 +24,7 @@ func TestPersistence(t *testing.T) {
|
||||
//conf.Server.DbPath = "./test-123.db"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
defer db.Init()()
|
||||
log.SetLevel(log.LevelError)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
@@ -35,8 +36,8 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
@@ -96,7 +97,7 @@ func P(path string) string {
|
||||
// Initialize test DB
|
||||
// TODO Load this data setup from file(s)
|
||||
var _ = BeforeSuite(func() {
|
||||
conn := NewDBXBuilder(db.Db())
|
||||
conn := GetDBXBuilder()
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
|
||||
@@ -199,3 +200,7 @@ var _ = BeforeSuite(func() {
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
})
|
||||
|
||||
func GetDBXBuilder() *dbx.DB {
|
||||
return dbx.NewFromDB(db.Db(), db.Driver)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -16,10 +15,6 @@ var _ = Describe("SQLStore", func() {
|
||||
BeforeEach(func() {
|
||||
ds = New(db.Db())
|
||||
ctx = context.Background()
|
||||
log.SetLevel(log.LevelFatal)
|
||||
})
|
||||
AfterEach(func() {
|
||||
log.SetLevel(log.LevelError)
|
||||
})
|
||||
Describe("WithTx", func() {
|
||||
Context("When block returns nil", func() {
|
||||
|
||||
@@ -21,6 +21,9 @@ func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerReposi
|
||||
r.registerModel(&model.Player{}, map[string]filterFunc{
|
||||
"name": containsFilter("player.name"),
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"user_name": "username", //TODO rename all user_name and userName to username
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -71,8 +74,33 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
|
||||
return append(s, Eq{"user_id": u.ID})
|
||||
}
|
||||
|
||||
func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) {
|
||||
sel := r.newSelect(options...).
|
||||
Columns(
|
||||
"case when client = 'NavidromeUI' then name else client end as player",
|
||||
"count(*) as count",
|
||||
).GroupBy("client")
|
||||
var res []struct {
|
||||
Player string
|
||||
Count int64
|
||||
}
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[string]int64, len(res))
|
||||
for _, c := range res {
|
||||
counts[c.Player] = c.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *playerRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
return r.count(r.newRestSelect(), options...)
|
||||
}
|
||||
|
||||
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newRestSelect(), r.parseRestOptions(r.ctx, options...))
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
|
||||
@@ -4,17 +4,17 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("PlayerRepository", func() {
|
||||
var adminRepo *playerRepository
|
||||
var database *dbxBuilder
|
||||
var database *dbx.DB
|
||||
|
||||
var (
|
||||
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
|
||||
@@ -28,7 +28,7 @@ var _ = Describe("PlayerRepository", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
|
||||
database = NewDBXBuilder(db.Db())
|
||||
database = GetDBXBuilder()
|
||||
adminRepo = NewPlayerRepository(ctx, database).(*playerRepository)
|
||||
|
||||
for idx := range players {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@@ -55,6 +55,9 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -304,14 +307,12 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
|
||||
}
|
||||
|
||||
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := slice.BreakUp(mediaFileIds, 200)
|
||||
|
||||
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
|
||||
// Add new tracks, chunk by chunk
|
||||
pos := startingPos
|
||||
for i := range chunks {
|
||||
for chunk := range slices.Chunk(mediaFileIds, 200) {
|
||||
ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id")
|
||||
for _, t := range chunks[i] {
|
||||
for _, t := range chunk {
|
||||
ins = ins.Values(playlistId, t, pos)
|
||||
pos++
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@@ -20,7 +19,7 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlaylistRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -26,17 +25,13 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
p.db = r.db
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, nil)
|
||||
p.sortMappings = map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
"album": "order_album_name asc, order_album_artist_name asc",
|
||||
"title": "order_title",
|
||||
}
|
||||
if conf.Server.PreferSortTags {
|
||||
p.sortMappings["artist"] = "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc"
|
||||
p.sortMappings["album"] = "COALESCE(NULLIF(sort_album_name,''),order_album_name)"
|
||||
p.sortMappings["title"] = "COALESCE(NULLIF(sort_title,''),title)"
|
||||
}
|
||||
p.setSortMappings(map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
})
|
||||
|
||||
pls, err := r.Get(playlistId)
|
||||
if err != nil {
|
||||
|
||||
@@ -42,6 +42,9 @@ func (r *playQueueRepository) Store(q *model.PlayQueue) error {
|
||||
log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err)
|
||||
return err
|
||||
}
|
||||
if len(q.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
pq := r.fromModel(q)
|
||||
if pq.ID == "" {
|
||||
pq.CreatedAt = time.Now()
|
||||
@@ -101,25 +104,22 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
|
||||
return q
|
||||
}
|
||||
|
||||
// loadTracks loads the tracks from the database. It receives a list of track IDs and returns a list of MediaFiles
|
||||
// in the same order as the input list.
|
||||
func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all ids
|
||||
ids := make([]string, len(tracks))
|
||||
for i, t := range tracks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
|
||||
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
|
||||
chunks := slice.BreakUp(ids, 50)
|
||||
|
||||
// Query each chunk of media_file ids and store results in a map
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
trackMap := map[string]model.MediaFile{}
|
||||
for i := range chunks {
|
||||
idsFilter := Eq{"media_file.id": chunks[i]}
|
||||
|
||||
// Create an iterator to collect all track IDs
|
||||
ids := slice.SeqFunc(tracks, func(t model.MediaFile) string { return t.ID })
|
||||
|
||||
// Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
|
||||
for chunk := range slice.CollectChunks(ids, 500) {
|
||||
idsFilter := Eq{"media_file.id": chunk}
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
|
||||
if err != nil {
|
||||
u := loggedUser(r.ctx)
|
||||
@@ -131,9 +131,12 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil
|
||||
}
|
||||
|
||||
// Create a new list of tracks with the same order as the original
|
||||
newTracks := make(model.MediaFiles, len(tracks))
|
||||
for i, t := range tracks {
|
||||
newTracks[i] = trackMap[t.ID]
|
||||
// Exclude tracks that are not in the DB anymore
|
||||
newTracks := make(model.MediaFiles, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
if track, ok := trackMap[t.ID]; ok {
|
||||
newTracks = append(newTracks, track)
|
||||
}
|
||||
}
|
||||
return newTracks
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -16,11 +15,12 @@ import (
|
||||
|
||||
var _ = Describe("PlayQueueRepository", func() {
|
||||
var repo model.PlayQueueRepository
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlayQueueRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
repo = NewPlayQueueRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("PlayQueues", func() {
|
||||
@@ -51,6 +51,37 @@ var _ = Describe("PlayQueueRepository", func() {
|
||||
AssertPlayQueue(another, actual)
|
||||
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not return tracks if they don't exist in the DB", func() {
|
||||
// Add a new song to the DB
|
||||
newSong := songRadioactivity
|
||||
newSong.ID = "temp-track"
|
||||
mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
|
||||
Expect(mfRepo.Put(&newSong)).To(Succeed())
|
||||
|
||||
// Create a playqueue with the new song
|
||||
pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
|
||||
Expect(repo.Store(pq)).To(Succeed())
|
||||
|
||||
// Retrieve the playqueue
|
||||
actual, err := repo.Retrieve("userid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The playqueue should contain both tracks
|
||||
AssertPlayQueue(pq, actual)
|
||||
|
||||
// Delete the new song
|
||||
Expect(mfRepo.Delete("temp-track")).To(Succeed())
|
||||
|
||||
// Retrieve the playqueue
|
||||
actual, err = repo.Retrieve("userid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The playqueue should not contain the deleted track
|
||||
Expect(actual.Items).To(HaveLen(1))
|
||||
Expect(actual.Items[0].ID).To(Equal(songAntenna.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -14,7 +13,7 @@ var _ = Describe("Property Repository", func() {
|
||||
var pr model.PropertyRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
pr = NewPropertyRepository(log.NewContext(context.TODO()), NewDBXBuilder(db.Db()))
|
||||
pr = NewPropertyRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
||||
})
|
||||
|
||||
It("saves and restore a new property", func() {
|
||||
|
||||
@@ -24,9 +24,6 @@ func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioReposito
|
||||
r.registerModel(&model.Radio{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "(name collate nocase), name",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -23,7 +22,7 @@ var _ = Describe("RadioRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewRadioRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
repo = NewRadioRepository(ctx, GetDBXBuilder())
|
||||
_ = repo.Put(&radioWithHomePage)
|
||||
})
|
||||
|
||||
@@ -120,7 +119,7 @@ var _ = Describe("RadioRepository", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
|
||||
repo = NewRadioRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
repo = NewRadioRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
|
||||
@@ -23,7 +23,10 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
|
||||
r := &shareRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Share{}, map[string]filterFunc{})
|
||||
r.registerModel(&model.Share{}, nil)
|
||||
r.setSortMappings(map[string]string{
|
||||
"username": "username",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -43,6 +46,7 @@ func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuild
|
||||
func (r *shareRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
}
|
||||
|
||||
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
||||
sel := r.selectShare().Where(Eq{"share.id": id})
|
||||
var res model.Share
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,17 +28,20 @@ import (
|
||||
// - Call registerModel with the model instance and any possible filters.
|
||||
// - If the model has a different table name than the default (lowercase of the model name), it should be set manually
|
||||
// using the tableName field.
|
||||
// - Sort mappings should be set in the sortMappings field. If the sort field is not in the map, it will be used as is.
|
||||
// - Sort mappings must be set with setSortMappings method. If a sort field is not in the map, it will be used as the name of the column.
|
||||
//
|
||||
// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or
|
||||
// defined in the mappings will be allowed.
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
sortMappings map[string]string
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
|
||||
// Do not set these fields manually, they are set by the registerModel method
|
||||
filterMappings map[string]filterFunc
|
||||
isFieldWhiteListed fieldWhiteListedFunc
|
||||
// Do not set this field manually, it is set by the setSortMappings method
|
||||
sortMappings map[string]string
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
@@ -68,6 +72,22 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
|
||||
r.filterMappings = filters
|
||||
}
|
||||
|
||||
// setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is.
|
||||
//
|
||||
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
||||
// which gives precedence to sort tags.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions
|
||||
func (r *sqlRepository) setSortMappings(mappings map[string]string) {
|
||||
if conf.Server.PreferSortTags {
|
||||
for k, v := range mappings {
|
||||
v = mapSortOrder(v)
|
||||
mappings[k] = v
|
||||
}
|
||||
}
|
||||
r.sortMappings = mappings
|
||||
}
|
||||
|
||||
func (r sqlRepository) getTableName() string {
|
||||
return r.tableName
|
||||
}
|
||||
@@ -120,11 +140,12 @@ func (r sqlRepository) buildSortOrder(sort, order string) string {
|
||||
reverseOrder = "desc"
|
||||
}
|
||||
|
||||
var newSort []string
|
||||
parts := strings.FieldsFunc(sort, splitFunc(','))
|
||||
newSort := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
f := strings.FieldsFunc(p, splitFunc(' '))
|
||||
newField := []string{f[0]}
|
||||
newField := make([]string, 1, len(f))
|
||||
newField[0] = f[0]
|
||||
if len(f) == 1 {
|
||||
newField = append(newField, order)
|
||||
} else {
|
||||
@@ -167,20 +188,15 @@ func (r sqlRepository) seedKey() string {
|
||||
return r.tableName + userId(r.ctx)
|
||||
}
|
||||
|
||||
func (r sqlRepository) seededRandomSort() string {
|
||||
return fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
|
||||
}
|
||||
|
||||
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
|
||||
if len(options) == 0 || options[0].Sort != "random" {
|
||||
return
|
||||
}
|
||||
|
||||
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
|
||||
if options[0].Seed != "" {
|
||||
hasher.SetSeed(r.seedKey(), options[0].Seed)
|
||||
return
|
||||
}
|
||||
|
||||
if options[0].Offset == 0 {
|
||||
hasher.Reseed(r.seedKey())
|
||||
}
|
||||
@@ -206,19 +222,23 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
var placeholderRegex = regexp.MustCompile(`\?`)
|
||||
|
||||
func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) {
|
||||
query, args, err := sq.ToSql()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
// Replace query placeholders with named params
|
||||
params := dbx.Params{}
|
||||
for i, arg := range args {
|
||||
p := fmt.Sprintf("p%d", i)
|
||||
query = strings.Replace(query, "?", "{:"+p+"}", 1)
|
||||
params[p] = arg
|
||||
}
|
||||
return query, params, nil
|
||||
params := make(dbx.Params, len(args))
|
||||
counter := 0
|
||||
result := placeholderRegex.ReplaceAllStringFunc(query, func(_ string) string {
|
||||
p := fmt.Sprintf("p%d", counter)
|
||||
params[p] = args[counter]
|
||||
counter++
|
||||
return "{:" + p + "}"
|
||||
})
|
||||
return result, params, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
||||
|
||||
@@ -6,16 +6,25 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
r := sqlRepository{}
|
||||
var r sqlRepository
|
||||
BeforeEach(func() {
|
||||
r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"})
|
||||
r.tableName = "table"
|
||||
})
|
||||
|
||||
Describe("applyOptions", func() {
|
||||
var sq squirrel.SelectBuilder
|
||||
BeforeEach(func() {
|
||||
sq = squirrel.Select("*").From("test")
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "title",
|
||||
}
|
||||
})
|
||||
It("does not add any clauses when options is empty", func() {
|
||||
sq = r.applyOptions(sq, model.QueryOptions{})
|
||||
@@ -30,17 +39,11 @@ var _ = Describe("sqlRepository", func() {
|
||||
Offset: 2,
|
||||
})
|
||||
sql, _, _ := sq.ToSql()
|
||||
Expect(sql).To(Equal("SELECT * FROM test ORDER BY name desc LIMIT 1 OFFSET 2"))
|
||||
Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("toSQL", func() {
|
||||
var r sqlRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
r = sqlRepository{}
|
||||
})
|
||||
|
||||
It("returns error for invalid SQL", func() {
|
||||
sq := squirrel.Select("*").From("test").Where(1)
|
||||
_, _, err := r.toSQL(sq)
|
||||
@@ -175,13 +178,37 @@ var _ = Describe("sqlRepository", func() {
|
||||
Expect(sql).To(Equal("name asc, coalesce(nullif(release_date, ''), nullif(original_date, '')) desc, status desc"))
|
||||
})
|
||||
})
|
||||
Context("seededRandomSort", func() {
|
||||
It("returns a random sort order function call", func() {
|
||||
r.ctx = request.WithUser(context.Background(), model.User{ID: "2"})
|
||||
r.tableName = "media_file"
|
||||
sql := r.seededRandomSort()
|
||||
Expect(sql).To(ContainSubstring("SEEDEDRAND('media_file2', media_file.id)"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resetSeededRandom", func() {
|
||||
var id string
|
||||
BeforeEach(func() {
|
||||
id = r.seedKey()
|
||||
hasher.SetSeed(id, "")
|
||||
})
|
||||
It("does not reset seed if sort is not random", func() {
|
||||
var options []model.QueryOptions
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(BeEmpty())
|
||||
})
|
||||
It("resets seed if sort is random", func() {
|
||||
options := []model.QueryOptions{{Sort: "random"}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty())
|
||||
})
|
||||
It("resets seed if sort is random and seed is provided", func() {
|
||||
options := []model.QueryOptions{{Sort: "random", Seed: "seed"}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
})
|
||||
It("keeps seed when paginating", func() {
|
||||
options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
|
||||
options = []model.QueryOptions{{Sort: "random", Offset: 1}}
|
||||
r.resetSeededRandom(options)
|
||||
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -17,7 +16,7 @@ var _ = Describe("sqlBookmarks", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid"})
|
||||
mr = NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
|
||||
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Bookmarks", func() {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (r sqlRepository) withGenres(sql SelectBuilder) SelectBuilder {
|
||||
@@ -22,19 +23,17 @@ func (r *sqlRepository) updateGenres(id string, genres model.Genres) error {
|
||||
if len(genres) == 0 {
|
||||
return nil
|
||||
}
|
||||
var genreIds []string
|
||||
for _, g := range genres {
|
||||
genreIds = append(genreIds, g.ID)
|
||||
}
|
||||
err = slice.RangeByChunks(genreIds, 100, func(ids []string) error {
|
||||
|
||||
for chunk := range slices.Chunk(genres, 100) {
|
||||
ins := Insert(tableName+"_genres").Columns("genre_id", tableName+"_id")
|
||||
for _, gid := range ids {
|
||||
ins = ins.Values(gid, id)
|
||||
for _, genre := range chunk {
|
||||
ins = ins.Values(genre.ID, id)
|
||||
}
|
||||
_, err = r.executeSQL(ins)
|
||||
return err
|
||||
})
|
||||
return err
|
||||
if _, err = r.executeSQL(ins); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type baseRepository interface {
|
||||
@@ -71,24 +70,24 @@ func appendGenre[T modelWithGenres](item *T, genre model.Genre) {
|
||||
|
||||
func loadGenres[T modelWithGenres](r baseRepository, ids []string, items map[string]*T) error {
|
||||
tableName := r.getTableName()
|
||||
return slice.RangeByChunks(ids, 900, func(ids []string) error {
|
||||
|
||||
for chunk := range slices.Chunk(ids, 900) {
|
||||
sql := Select("genre.*", tableName+"_id as item_id").From("genre").
|
||||
Join(tableName+"_genres ig on genre.id = ig.genre_id").
|
||||
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": ids})
|
||||
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": chunk})
|
||||
|
||||
var genres []struct {
|
||||
model.Genre
|
||||
ItemID string
|
||||
}
|
||||
err := r.queryAll(sql, &genres)
|
||||
if err != nil {
|
||||
if err := r.queryAll(sql, &genres); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range genres {
|
||||
appendGenre(items[g.ItemID], g.Genre)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAllGenres[T modelWithGenres](r baseRepository, items []T) error {
|
||||
|
||||
@@ -29,7 +29,9 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query
|
||||
// Look for a custom filter function
|
||||
f = strings.ToLower(f)
|
||||
if ff, ok := r.filterMappings[f]; ok {
|
||||
filters = append(filters, ff(f, v))
|
||||
if filter := ff(f, v); filter != nil {
|
||||
filters = append(filters, filter)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Ignore invalid filters (not based on a field or filter function)
|
||||
|
||||
@@ -23,6 +23,24 @@ var _ = Describe("sqlRestful", func() {
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
|
||||
})
|
||||
|
||||
It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
}
|
||||
options.Filters = map[string]interface{}{"name": "'"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not add nill filters", func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": func(string, any) squirrel.Sqlizer {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns a '=' condition for 'id' filter", func() {
|
||||
options.Filters = map[string]interface{}{"id": "123"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user