mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge remote-tracking branch 'upstream/master' into fix/library-path-error-classification-4595
# Conflicts: # core/library.go
This commit is contained in:
commit
2536f83118
800 changed files with 39883 additions and 12441 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.26",
|
||||
"VARIANT": "1.27",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24"
|
||||
|
|
|
|||
5
.github/actions/prepare-docker/action.yml
vendored
5
.github/actions/prepare-docker/action.yml
vendored
|
|
@ -68,6 +68,11 @@ runs:
|
|||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
|
|
|
|||
60
.github/workflows/coverage-on-pr.yml
vendored
Normal file
60
.github/workflows/coverage-on-pr.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Report coverage on PR
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Pipeline: Test, Lint, Build']
|
||||
types: [completed]
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment coverage report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
env:
|
||||
COVERAGE_COMMENT: 'true'
|
||||
steps:
|
||||
# Only the config, from the base branch: this job holds a write token, so
|
||||
# it must never check out the fork.
|
||||
- name: Check out the octocov config
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: .octocov.yml
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
# Into a subdirectory. A pull_request run executes the fork's own copy of
|
||||
# pipeline.yml, so every file in here is attacker-controlled.
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: octocov-pr
|
||||
path: untrusted
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Verify the artifact and take the coverage profile
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]')
|
||||
case "$number" in ''|*[!0-9]*)
|
||||
echo "::error::artifact pr_number is not a number"; exit 1;;
|
||||
esac
|
||||
sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha)
|
||||
if [ "$sha" != "$HEAD_SHA" ]; then
|
||||
echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1
|
||||
fi
|
||||
cp untrusted/coverage.out coverage.out
|
||||
echo "number=$number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: k1LoW/octocov-action@v1
|
||||
env:
|
||||
# A workflow_run job looks like a push to the default branch. Point
|
||||
# octocov back at the pull request and at the run that produced it.
|
||||
GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge
|
||||
OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
11
.github/workflows/download-link-on-pr.yml
vendored
11
.github/workflows/download-link-on-pr.yml
vendored
|
|
@ -34,16 +34,19 @@ jobs:
|
|||
}
|
||||
|
||||
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||
if (!artifacts.length) {
|
||||
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
|
||||
if (!downloadable.length) {
|
||||
return core.error(`No artifacts found`);
|
||||
}
|
||||
let body = `Download the artifacts for this pull request:\n`;
|
||||
for (const art of artifacts) {
|
||||
const header = `Download the artifacts for this pull request:`;
|
||||
let body = `${header}\n`;
|
||||
for (const art of downloadable) {
|
||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||
}
|
||||
|
||||
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
|
||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
|
||||
// Match on the body too: octocov also comments as github-actions[bot].
|
||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
|
||||
if (existing_comment) {
|
||||
core.info(`Updating comment ${existing_comment.id}`);
|
||||
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||
|
|
|
|||
125
.github/workflows/pipeline.yml
vendored
125
.github/workflows/pipeline.yml
vendored
|
|
@ -68,10 +68,16 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Keep CI on the same version `make lint` installs, so a clean local run
|
||||
# cannot turn red in CI just because a new golangci-lint was released.
|
||||
- name: Resolve golangci-lint version
|
||||
id: golangci-version
|
||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
version: ${{ steps.golangci-version.outputs.version }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
|
|
@ -86,8 +92,23 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve OpenAPI tool versions
|
||||
id: api-tools
|
||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache OpenAPI tools
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: bin
|
||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
||||
|
||||
- name: Lint OpenAPI spec
|
||||
run: make api-lint
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
run: |
|
||||
make api-gen
|
||||
go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
|
|
@ -96,6 +117,12 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for breaking OpenAPI changes
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
||||
|
||||
validate-migrations:
|
||||
name: Validate DB migrations
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -131,8 +158,10 @@ jobs:
|
|||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Test
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
# Name must stay unique across the workflow: octocov matches step names
|
||||
# by name across every job, and waits for each match to finish.
|
||||
- name: Test with coverage
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
|
|
@ -141,6 +170,84 @@ jobs:
|
|||
go build -o ndpgen .
|
||||
./ndpgen --help
|
||||
|
||||
- name: Upload coverage profile
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: octocov-go
|
||||
path: coverage.out
|
||||
if-no-files-found: error
|
||||
|
||||
go-plugins:
|
||||
name: Test Go plugins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
id: setup-go
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Without this, the suite recompiles every test plugin WASM module,
|
||||
# which dominates its runtime under -race.
|
||||
- name: Cache the WASM compilation cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: plugins/testdata/.wazero-cache
|
||||
key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }}
|
||||
restore-keys: wazero-${{ runner.os }}-
|
||||
|
||||
- name: Test plugins
|
||||
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
|
||||
|
||||
- name: Upload coverage profile
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: octocov-plugins
|
||||
path: coverage.out
|
||||
if-no-files-found: error
|
||||
|
||||
coverage:
|
||||
name: Report coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [go, go-plugins]
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
env:
|
||||
COVERAGE_COMMENT: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: octocov-*
|
||||
|
||||
# Merge here rather than letting octocov do it: octocov reports statement
|
||||
# coverage for a single profile, but switches to line counting for several.
|
||||
- name: Merge coverage profiles
|
||||
run: |
|
||||
echo "mode: atomic" > coverage.out
|
||||
awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \
|
||||
octocov-*/coverage.out | sort >> coverage.out
|
||||
|
||||
- uses: k1LoW/octocov-action@v1
|
||||
|
||||
- name: Save the PR number for the comment workflow
|
||||
if: github.event_name == 'pull_request'
|
||||
run: echo "${{ github.event.pull_request.number }}" > pr_number
|
||||
|
||||
- name: Upload the merged profile for the comment workflow
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: octocov-pr
|
||||
path: |
|
||||
coverage.out
|
||||
pr_number
|
||||
if-no-files-found: error
|
||||
|
||||
go-windows:
|
||||
name: Test Go code (Windows)
|
||||
runs-on: windows-2022
|
||||
|
|
@ -207,12 +314,12 @@ jobs:
|
|||
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
shell: pwsh
|
||||
shell: bash
|
||||
run: |
|
||||
cd plugins\cmd\ndpgen
|
||||
cd plugins/cmd/ndpgen
|
||||
go test -shuffle=on -v
|
||||
go build -o ndpgen.exe .
|
||||
.\ndpgen.exe --help
|
||||
./ndpgen.exe --help
|
||||
|
||||
js:
|
||||
name: Test JS code
|
||||
|
|
@ -278,7 +385,7 @@ jobs:
|
|||
|
||||
build:
|
||||
name: Build
|
||||
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
|
|
@ -463,7 +570,7 @@ jobs:
|
|||
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
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||
done
|
||||
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -43,4 +43,8 @@ go.work*
|
|||
.playwright-mcp/
|
||||
|
||||
# Temp benchmark files
|
||||
zz_*_test.go
|
||||
zz_*_test.go
|
||||
|
||||
# wazero compilation cache for the plugins test suite
|
||||
/plugins/testdata/.wazero-cache/
|
||||
/plugins/testdata/*.stage/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ linters:
|
|||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
|
|
@ -27,6 +28,9 @@ linters:
|
|||
disable:
|
||||
- staticcheck
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
|
|
@ -54,6 +58,9 @@ linters:
|
|||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- containedctx
|
||||
- path-except: 'db/migrations/'
|
||||
linters:
|
||||
- forbidigo
|
||||
|
|
|
|||
44
.octocov.yml
Normal file
44
.octocov.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov
|
||||
# The 30s default is not enough: scanning this repo's artifacts for the baseline
|
||||
# eats most of it, leaving none for the report upload.
|
||||
timeout: 5m
|
||||
coverage:
|
||||
# A single pre-merged profile: octocov reports statements for one path, but
|
||||
# switches to line counting when it merges several itself.
|
||||
paths:
|
||||
- coverage.out
|
||||
# Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated.
|
||||
# Both patterns need the '**/' prefix: the comment workflow has no source tree,
|
||||
# so octocov cannot shorten the profile's import paths to repo-relative ones.
|
||||
exclude:
|
||||
- '**/tests/**'
|
||||
- '**/*_gen.go'
|
||||
codeToTestRatio:
|
||||
# Needs the pull request's own source, which the comment workflow must not
|
||||
# check out: it holds a write token.
|
||||
if: env.COVERAGE_COMMENT != 'true'
|
||||
code:
|
||||
- '**/*.go'
|
||||
- '!**/*_test.go'
|
||||
- '!**/*_gen.go'
|
||||
test:
|
||||
- '**/*_test.go'
|
||||
testExecutionTime:
|
||||
if: true
|
||||
steps:
|
||||
- Test with coverage
|
||||
- Test plugins
|
||||
diff:
|
||||
datastores:
|
||||
- artifact://${GITHUB_REPOSITORY}
|
||||
comment:
|
||||
# Only the 'Report coverage on PR' workflow sets this: a pull_request run from
|
||||
# a fork gets a read-only token, so commenting from here 403s.
|
||||
if: env.COVERAGE_COMMENT == 'true'
|
||||
updatePrevious: true
|
||||
summary:
|
||||
if: true
|
||||
report:
|
||||
if: is_default_branch
|
||||
datastores:
|
||||
- artifact://${GITHUB_REPOSITORY}
|
||||
47
Dockerfile
47
Dockerfile
|
|
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
|||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
|
||||
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
|
|
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome UI
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
|
||||
WORKDIR /app
|
||||
|
||||
# Install node dependencies
|
||||
|
|
@ -43,7 +43,7 @@ COPY --from=ui /build /build
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
|
@ -85,7 +85,7 @@ EOT
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
|
@ -152,19 +152,52 @@ RUN xx-verify --static /out/navidrome*
|
|||
FROM scratch AS binary
|
||||
COPY --from=build /out /
|
||||
|
||||
########################################################################################################################
|
||||
### Build no-op stubs for mpv's video-output libraries
|
||||
# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio.
|
||||
# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls.
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs
|
||||
COPY --from=xx / /
|
||||
RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm
|
||||
ARG TARGETPLATFORM
|
||||
RUN xx-apk add --no-cache musl-dev
|
||||
RUN <<EOT
|
||||
set -e
|
||||
mkdir -p /out
|
||||
for so in libEGL.so.1 libgbm.so.1; do
|
||||
readelf -sW /usr/lib/$so \
|
||||
| awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \
|
||||
| sed 's/@.*//' \
|
||||
| grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \
|
||||
| sort -u \
|
||||
| awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c
|
||||
test -s /tmp/stub.c
|
||||
xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c
|
||||
xx-verify /out/$so
|
||||
done
|
||||
EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
FROM alpine:3.22 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
# Install runtime dependencies
|
||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
|
||||
# otherwise the deleted bytes still ship in the image.
|
||||
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
for lib in libwebp libwebpdemux libwebpmux; do \
|
||||
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
||||
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
||||
done
|
||||
done && \
|
||||
rm -rf /usr/lib/gallium-pipe /usr/lib/dri \
|
||||
/usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \
|
||||
/usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so*
|
||||
|
||||
COPY --from=mpv-stubs /out/ /usr/lib/
|
||||
RUN mpv --no-video --ao=null --version > /dev/null
|
||||
|
||||
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
||||
COPY --from=build-alpine /out/navidrome /app/
|
||||
|
|
|
|||
47
Makefile
47
Makefile
|
|
@ -20,7 +20,11 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
|
|||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
GOLANGCI_LINT_VERSION ?= v2.12.0
|
||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||
VACUUM_VERSION ?= v0.30.6
|
||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
||||
OASDIFF_VERSION ?= v1.32.1
|
||||
API_DIFF_BASE ?= origin/master
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
|
@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||
fi
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
||||
echo "Installing OpenAPI tools..."; \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
||||
fi
|
||||
.PHONY: install-api-tools
|
||||
|
||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
||||
.PHONY: api-lint
|
||||
|
||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
||||
.PHONY: api-bundle
|
||||
|
||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
||||
.PHONY: api-gen
|
||||
|
||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
||||
if [ -z "$$BASE" ]; then \
|
||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
||||
BASE=$(API_DIFF_BASE); \
|
||||
fi; \
|
||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
||||
else \
|
||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
||||
fi
|
||||
.PHONY: api-diff
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
|
@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
|||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||
|
|
|
|||
|
|
@ -13,15 +13,26 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const apiBaseURL = "https://api.deezer.com"
|
||||
const authBaseURL = "https://auth.deezer.com"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("deezer: not found")
|
||||
)
|
||||
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
|
||||
// and no rate-limit headers, so the body code is the only signal.
|
||||
const errCodeQuota = 4
|
||||
|
||||
type deezerError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
func (e *deezerError) Error() string {
|
||||
return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
|
|
@ -56,7 +67,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
|
|||
}
|
||||
|
||||
if len(results.Data) == 0 {
|
||||
return nil, ErrNotFound
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
return results.Data, nil
|
||||
}
|
||||
|
|
@ -74,20 +85,31 @@ func (c *client) makeRequest(req *http.Request, response any) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Checked before the status: a throttled request still answers 200, and decoding its body
|
||||
// into a result type yields an empty one, which reads as "nothing found".
|
||||
if err := parseBodyError(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return c.parseError(data)
|
||||
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, response)
|
||||
}
|
||||
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var deezerError Error
|
||||
err := json.Unmarshal(data, &deezerError)
|
||||
if err != nil {
|
||||
return err
|
||||
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
|
||||
func parseBodyError(data []byte) error {
|
||||
var body errorResponse
|
||||
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
|
||||
_ = json.Unmarshal(data, &body)
|
||||
switch {
|
||||
case body.Error == nil:
|
||||
return nil
|
||||
case body.Error.Code == errCodeQuota:
|
||||
return errors.Join(body.Error, agents.ErrRetryLater)
|
||||
default:
|
||||
return body.Error
|
||||
}
|
||||
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
|
||||
}
|
||||
|
||||
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ package deezer
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -41,7 +43,37 @@ var _ = Describe("client", func() {
|
|||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
// Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal.
|
||||
It("reports an exhausted quota as a retryable error, not as a missing artist", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(
|
||||
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).ToNot(MatchError(agents.ErrNotFound),
|
||||
"a throttled lookup would otherwise settle the artist as having no image")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
Expect(err.Error()).To(ContainSubstring("Quota limit exceeded"))
|
||||
})
|
||||
|
||||
It("reports a non-quota body error as a plain error", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(
|
||||
`{"error":{"type":"Exception","message":"Invalid query","code":100}}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).ToNot(MatchError(agents.ErrNotFound))
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(),
|
||||
"only a throttle asks the caller to come back later")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
|
|
@ -15,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
|
|
@ -36,9 +36,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
|||
dataStore: dataStore,
|
||||
languages: conf.Server.Deezer.Languages,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||
agent.client = newClient(cachedHttpClient)
|
||||
return agent
|
||||
|
|
@ -93,9 +91,6 @@ func isPlaceholderPicture(url string) bool {
|
|||
|
||||
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
||||
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package deezer
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -80,6 +81,22 @@ var _ = Describe("deezerAgent", func() {
|
|||
Expect(artist.ID).To(Equal(2))
|
||||
})
|
||||
|
||||
// The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled
|
||||
// lookup reaching that here would record a permanent absence.
|
||||
It("surfaces an exhausted quota instead of reporting the artist as not found", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(
|
||||
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
|
||||
})
|
||||
|
||||
_, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).ToNot(MatchError(agents.ErrNotFound))
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no result matches the name exactly", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
|
|
|
|||
|
|
@ -22,12 +22,8 @@ type Artist struct {
|
|||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
type errorResponse struct {
|
||||
Error *deezerError `json:"error"`
|
||||
}
|
||||
|
||||
type RelatedArtists struct {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ var _ = Describe("Responses", func() {
|
|||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var errorResp Error
|
||||
var errorResp errorResponse
|
||||
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
|
||||
err := json.Unmarshal(body, &errorResp)
|
||||
Expect(err).To(BeNil())
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
|
|
@ -59,9 +60,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.httpClient = chc
|
||||
l.client = newClient(l.apiKey, l.secret, chc)
|
||||
|
|
@ -242,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
|||
var (
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||
|
||||
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
|
||||
// methods, which the page block does not affect.
|
||||
errNoArtistPage = errors.New("no artist image in Last.fm page")
|
||||
)
|
||||
|
||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
|
|
@ -268,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||
var res []agents.ExternalImage
|
||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||
if n == nil {
|
||||
return res, nil
|
||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
||||
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
|
||||
return nil, errNoArtistPage
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key != "content" {
|
||||
|
|
@ -406,7 +411,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
|||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 {
|
||||
// 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client.
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
|
|
|
|||
|
|
@ -100,6 +100,15 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Language Fallback", func() {
|
||||
|
|
@ -348,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
|
|
@ -497,6 +506,16 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
||||
|
|
@ -629,18 +648,41 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty list if page has no meta tags", func() {
|
||||
It("errors when the page has no meta tags", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
})
|
||||
|
||||
It("errors when Last.fm serves a bot challenge page", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not park the agent: the failure is not a retry-later", func() {
|
||||
// A RetryLaterError would cool down the agent's API-backed methods too.
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns error if API call fails", func() {
|
||||
apiClient.Err = errors.New("api error")
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
|
|
@ -41,9 +42,7 @@ func NewRouter(ds model.DataStore) *Router {
|
|||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
r.client = newClient(r.apiKey, r.secret, hc)
|
||||
return r
|
||||
}
|
||||
|
|
@ -133,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
|||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||
sessionKey, err := s.client.getSession(ctx, token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
|||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
|
|
@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() {
|
|||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
|
||||
It("rejects a Jellyfin access token", func() {
|
||||
usr := &model.User{ID: "u1", UserName: "johndoe"}
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -14,11 +15,15 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
||||
// errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200
|
||||
// and no rate-limit headers, so the body code is the only signal.
|
||||
errCodeRateLimit = 29
|
||||
)
|
||||
|
||||
type lastFMError struct {
|
||||
|
|
@ -44,6 +49,12 @@ type client struct {
|
|||
hc httpDoer
|
||||
}
|
||||
|
||||
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
|
||||
// album.getInfo decodes only once, so it must not use this.
|
||||
func escapePlus(s string) string {
|
||||
return strings.ReplaceAll(s, "+", "%2B")
|
||||
}
|
||||
|
||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "album.getInfo")
|
||||
|
|
@ -61,7 +72,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
|||
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("artist", name)
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("lang", lang)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -73,7 +84,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
|
|||
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getSimilar")
|
||||
params.Add("artist", name)
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -85,7 +96,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
|
|||
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getTopTracks")
|
||||
params.Add("artist", name)
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -97,8 +108,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
|||
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.getSimilar")
|
||||
params.Add("track", name)
|
||||
params.Add("artist", artist)
|
||||
params.Add("track", escapePlus(name))
|
||||
params.Add("artist", escapePlus(artist))
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -225,7 +236,11 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
|||
return nil, jsonErr
|
||||
}
|
||||
if response.Error != 0 {
|
||||
return &response, &lastFMError{Code: response.Error, Message: response.Message}
|
||||
var err error = &lastFMError{Code: response.Error, Message: response.Message}
|
||||
if response.Error == errCodeRateLimit {
|
||||
err = errors.Join(err, &agents.RetryLaterError{})
|
||||
}
|
||||
return &response, err
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@ var _ = Describe("client", func() {
|
|||
Expect(album.Name).To(Equal("Believe"))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
||||
})
|
||||
|
||||
It("does not double-encode plus signs", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("artistGetInfo", func() {
|
||||
|
|
@ -48,6 +57,15 @@ var _ = Describe("client", func() {
|
|||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||
})
|
||||
|
||||
It("double-encodes plus signs in the artist name", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
|
||||
})
|
||||
|
||||
It("fails if Last.fm returns an http status != 200", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||
|
|
@ -107,6 +125,15 @@ var _ = Describe("client", func() {
|
|||
Expect(len(similar.Artists)).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
||||
})
|
||||
|
||||
It("double-encodes plus signs in the artist name", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("artistGetTopTracks", func() {
|
||||
|
|
@ -119,6 +146,15 @@ var _ = Describe("client", func() {
|
|||
Expect(len(top.Track)).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
||||
})
|
||||
|
||||
It("double-encodes plus signs in the artist name", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("trackGetSimilar", func() {
|
||||
|
|
@ -135,6 +171,17 @@ var _ = Describe("client", func() {
|
|||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
||||
})
|
||||
|
||||
It("double-encodes plus signs in the track and artist names", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
query := httpClient.SavedRequest.URL.Query()
|
||||
Expect(query.Get("track")).To(Equal("1%2B1"))
|
||||
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
|
||||
})
|
||||
|
||||
It("returns empty list when no similar tracks found", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package listenbrainz
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -12,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
|
|
@ -33,9 +33,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
|||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = newClient("http://localhost:8080", httpClient)
|
||||
|
|
@ -164,6 +164,19 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||
})
|
||||
|
||||
It("keeps a 429 scrobble for retry and carries the delay", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistUrl", func() {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
)
|
||||
|
||||
type sessionKeysRepo interface {
|
||||
|
|
@ -37,9 +38,7 @@ func NewRouter(ds model.DataStore) *Router {
|
|||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
|
|
@ -21,6 +22,12 @@ const (
|
|||
labsBase = "https://labs.api.listenbrainz.org/"
|
||||
)
|
||||
|
||||
// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In
|
||||
// (delta-seconds) on every response, including the 429, and never Retry-After.
|
||||
func retryLaterErr(h http.Header) *agents.RetryLaterError {
|
||||
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrorNotFound = errors.New("listenbrainz: not found")
|
||||
)
|
||||
|
|
@ -174,6 +181,9 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
|||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, retryLaterErr(resp.Header)
|
||||
}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response listenBrainzResponse
|
||||
|
|
@ -185,6 +195,10 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
|||
return nil, jsonErr
|
||||
}
|
||||
if response.Code != 0 && response.Code != 200 {
|
||||
// LB also reports rate limiting as a body code, not only as an HTTP status.
|
||||
if response.Code == http.StatusTooManyRequests {
|
||||
return &response, retryLaterErr(resp.Header)
|
||||
}
|
||||
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +225,9 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint
|
|||
// On a 200 code, there is no code. Decode using using error message if it exists
|
||||
if resp.StatusCode != 200 {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, retryLaterErr(resp.Header)
|
||||
}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var lbzError lbzHttpError
|
||||
|
|
|
|||
|
|
@ -4,13 +4,17 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -461,4 +465,73 @@ var _ = Describe("client", func() {
|
|||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("rate limiting", func() {
|
||||
It("returns RetryLaterError with the header delay on 429", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(3 * time.Second))
|
||||
})
|
||||
|
||||
It("returns RetryLaterError with zero delay when no header is present", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(retry.RetryIn).To(BeZero())
|
||||
})
|
||||
|
||||
DescribeTable("caps absurd header values at one hour",
|
||||
func(header string) {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(retry.RetryIn).To(Equal(time.Hour))
|
||||
},
|
||||
Entry("a large value", "999999"),
|
||||
Entry("a huge value", "99999999999"),
|
||||
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
|
||||
Entry("a value that overflows int64 nanoseconds", "18446744074"),
|
||||
)
|
||||
|
||||
It("maps a body-level 429 sent with a non-429 status", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
||||
})
|
||||
|
||||
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.getArtistUrl(context.Background(), "1")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(5 * time.Second))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
1
api/.oasdiff-levels.txt
Normal file
1
api/.oasdiff-levels.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
response-property-enum-value-added INFO
|
||||
155
api/.vacuum.yaml
Normal file
155
api/.vacuum.yaml
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
extends: [[spectral:oas, recommended]]
|
||||
rules:
|
||||
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
|
||||
# so the value check below targets `x-module` via `given` instead.
|
||||
nd-operation-x-module-required:
|
||||
description: Every operation belongs to exactly one capability module.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: x-module
|
||||
function: truthy
|
||||
nd-operation-x-module:
|
||||
description: Every operation's capability module is one of the known values.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]['x-module']
|
||||
then:
|
||||
function: enumeration
|
||||
functionOptions:
|
||||
values:
|
||||
- core
|
||||
- streaming
|
||||
- download
|
||||
- artwork
|
||||
- lyrics
|
||||
- transcoding
|
||||
- annotations
|
||||
- playback
|
||||
- queue
|
||||
- custom-tags
|
||||
- grouping
|
||||
- playlists
|
||||
- smart-playlists
|
||||
- sync
|
||||
- events
|
||||
- jukebox
|
||||
- sharing
|
||||
- radio
|
||||
- admin
|
||||
nd-operation-stability-level-required:
|
||||
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: x-stability-level
|
||||
function: truthy
|
||||
nd-operation-stability-level:
|
||||
description: Every operation's stability level is alpha, beta, or stable.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
|
||||
then:
|
||||
function: enumeration
|
||||
functionOptions:
|
||||
values:
|
||||
- alpha
|
||||
- beta
|
||||
- stable
|
||||
nd-operation-required-fields:
|
||||
description: Operations need a stable operationId, summary, description and tags.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
- field: operationId
|
||||
function: truthy
|
||||
- field: summary
|
||||
function: truthy
|
||||
- field: description
|
||||
function: truthy
|
||||
- field: tags
|
||||
function: truthy
|
||||
# Our schemas live in path/response files, not root components, so this
|
||||
# walks every resolved `properties` map in the document via `$..` instead.
|
||||
nd-schema-property-descriptions:
|
||||
description: Every schema property is documented.
|
||||
severity: error
|
||||
given: $..properties[*]
|
||||
then:
|
||||
field: description
|
||||
function: truthy
|
||||
# patternProperties covers the full 4xx/5xx range; needs an explicit
|
||||
# `properties` entry too, or `additionalProperties: false` rejects it.
|
||||
nd-error-responses-are-problems:
|
||||
description: 4xx and 5xx responses use application/problem+json.
|
||||
severity: error
|
||||
given: $.paths[*][*].responses
|
||||
then:
|
||||
function: schema
|
||||
functionOptions:
|
||||
forceValidationOnCurrentNode: true
|
||||
schema:
|
||||
type: object
|
||||
patternProperties:
|
||||
"^[45][0-9][0-9]$":
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: object
|
||||
properties:
|
||||
application/problem+json: {}
|
||||
required: [application/problem+json]
|
||||
additionalProperties: false
|
||||
# Same filter limitation applies here: "is this a list endpoint" is expressed
|
||||
# as a JSON Schema if/then on the operation object instead of a `given` filter.
|
||||
nd-list-endpoints-paginate:
|
||||
description: List endpoints declare the shared offset and limit parameters.
|
||||
severity: error
|
||||
given: $.paths[*].get
|
||||
then:
|
||||
function: schema
|
||||
functionOptions:
|
||||
forceValidationOnCurrentNode: true
|
||||
schema:
|
||||
type: object
|
||||
if:
|
||||
required: [responses]
|
||||
properties:
|
||||
responses:
|
||||
type: object
|
||||
required: ['200']
|
||||
properties:
|
||||
'200':
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: object
|
||||
required: [application/json]
|
||||
properties:
|
||||
application/json:
|
||||
type: object
|
||||
required: [schema]
|
||||
properties:
|
||||
schema:
|
||||
type: object
|
||||
required: [properties]
|
||||
properties:
|
||||
properties:
|
||||
type: object
|
||||
required: [items]
|
||||
then:
|
||||
required: [parameters]
|
||||
properties:
|
||||
parameters:
|
||||
type: array
|
||||
allOf:
|
||||
- contains:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
const: offset
|
||||
- contains:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
const: limit
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package ffmpeg
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -9,9 +9,9 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFFMpeg(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
func TestAPI(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "FFMpeg Suite")
|
||||
RunSpecs(t, "API Spec Suite")
|
||||
}
|
||||
262
api/bundled/openapi.json
Normal file
262
api/bundled/openapi.json
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Navidrome API",
|
||||
"version": "1.0.0",
|
||||
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
|
||||
"license": {
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/api/v1"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "server",
|
||||
"description": "Server discovery and the published OpenAPI document."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/server": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Describe the server",
|
||||
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server description.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecJSON",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Get the OpenAPI document (JSON)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The OpenAPI document.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI 3.0 document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"304": {
|
||||
"$ref": "#/components/responses/NotModified"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.yaml": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecYAML",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Get the OpenAPI document (YAML)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The OpenAPI document.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/yaml": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI 3.0 document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"304": {
|
||||
"$ref": "#/components/responses/NotModified"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"ServerInfo": {
|
||||
"type": "object",
|
||||
"description": "Public server description. Everything an add-server screen needs before login.",
|
||||
"required": [
|
||||
"name",
|
||||
"serverVersion",
|
||||
"specVersion",
|
||||
"setupRequired",
|
||||
"loginMethods"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable server product name."
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the running server build."
|
||||
},
|
||||
"specVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the OpenAPI document this server implements."
|
||||
},
|
||||
"setupRequired": {
|
||||
"type": "boolean",
|
||||
"description": "True until the first admin user has been created."
|
||||
},
|
||||
"loginMethods": {
|
||||
"type": "array",
|
||||
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Problem": {
|
||||
"type": "object",
|
||||
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
||||
"required": [
|
||||
"title",
|
||||
"status",
|
||||
"code"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short human-readable summary, the same for all occurrences of this problem type."
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"description": "HTTP status code of this response."
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
|
||||
"enum": [
|
||||
"validation",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"method_not_allowed",
|
||||
"unavailable",
|
||||
"internal"
|
||||
]
|
||||
},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"description": "Per-field failures. Present only when `code` is `validation`.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValidationError": {
|
||||
"type": "object",
|
||||
"description": "One field-level validation failure.",
|
||||
"required": [
|
||||
"field",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Why the value was rejected."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"InternalError": {
|
||||
"description": "Unexpected server failure. Details are in the server log.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NotModified": {
|
||||
"description": "Not modified.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"description": "Entity tag for `If-None-Match` revalidation.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
api/bundled/openapi.yaml
Normal file
194
api/bundled/openapi.yaml
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Navidrome API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||
capability modules through `GET /server` and never sniff versions.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||
disappear without notice, `beta` and `stable` operations only change additively.
|
||||
A level is only ever raised, never lowered.
|
||||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
paths:
|
||||
/server:
|
||||
get:
|
||||
operationId: getServerInfo
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Describe the server
|
||||
description: |
|
||||
Returns the public server description. No authentication required.
|
||||
Authenticated requests will additionally receive the implemented capability modules
|
||||
once authentication is available.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerInfo'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/openapi.json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
/openapi.yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
schemas:
|
||||
ServerInfo:
|
||||
type: object
|
||||
description: Public server description. Everything an add-server screen needs before login.
|
||||
required:
|
||||
- name
|
||||
- serverVersion
|
||||
- specVersion
|
||||
- setupRequired
|
||||
- loginMethods
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable server product name.
|
||||
serverVersion:
|
||||
type: string
|
||||
description: Version of the running server build.
|
||||
specVersion:
|
||||
type: string
|
||||
description: Version of the OpenAPI document this server implements.
|
||||
setupRequired:
|
||||
type: boolean
|
||||
description: True until the first admin user has been created.
|
||||
loginMethods:
|
||||
type: array
|
||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- password
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
required:
|
||||
- title
|
||||
- status
|
||||
- code
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: |
|
||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
own semantics get their own URI; switch on `code` instead.
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- unavailable
|
||||
- internal
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
ValidationError:
|
||||
type: object
|
||||
description: One field-level validation failure.
|
||||
required:
|
||||
- field
|
||||
- message
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
responses:
|
||||
InternalError:
|
||||
description: Unexpected server failure. Details are in the server log.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
NotModified:
|
||||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
headers:
|
||||
ETag:
|
||||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
35
api/embed.go
Normal file
35
api/embed.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed bundled/openapi.json
|
||||
var specJSON []byte
|
||||
|
||||
//go:embed bundled/openapi.yaml
|
||||
var specYAML []byte
|
||||
|
||||
func SpecJSON() []byte {
|
||||
return specJSON
|
||||
}
|
||||
|
||||
func SpecYAML() []byte {
|
||||
return specYAML
|
||||
}
|
||||
|
||||
var specVersion = sync.OnceValue(func() string {
|
||||
var doc struct {
|
||||
Info struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"info"`
|
||||
}
|
||||
_ = json.Unmarshal(SpecJSON(), &doc)
|
||||
return doc.Info.Version
|
||||
})
|
||||
|
||||
func SpecVersion() string {
|
||||
return specVersion()
|
||||
}
|
||||
37
api/embed_test.go
Normal file
37
api/embed_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var _ = Describe("Bundled spec", func() {
|
||||
It("embeds a valid OpenAPI 3 document", func() {
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
|
||||
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("embeds the YAML variant", func() {
|
||||
var doc map[string]any
|
||||
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
|
||||
Expect(doc).To(HaveKey("paths"))
|
||||
})
|
||||
|
||||
It("reports the version from the bundle, matching the source root document", func() {
|
||||
src, err := os.ReadFile("api/openapi/openapi.yaml")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var root struct {
|
||||
Info struct{ Version string } `yaml:"info"`
|
||||
}
|
||||
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
|
||||
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
|
||||
Expect(api.SpecVersion()).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
3
api/openapi/components/headers/ETag.yaml
Normal file
3
api/openapi/components/headers/ETag.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
9
api/openapi/components/parameters/limit.yaml
Normal file
9
api/openapi/components/parameters/limit.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name: limit
|
||||
in: query
|
||||
description: Maximum number of items to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000
|
||||
default: 100
|
||||
8
api/openapi/components/parameters/offset.yaml
Normal file
8
api/openapi/components/parameters/offset.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: offset
|
||||
in: query
|
||||
description: Zero-based index of the first item to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
5
api/openapi/components/responses/BadRequest.yaml
Normal file
5
api/openapi/components/responses/BadRequest.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: The request is malformed or fails validation.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/Forbidden.yaml
Normal file
5
api/openapi/components/responses/Forbidden.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: The caller is authenticated but not allowed to do this.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/InternalError.yaml
Normal file
5
api/openapi/components/responses/InternalError.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: Unexpected server failure. Details are in the server log.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/NotFound.yaml
Normal file
5
api/openapi/components/responses/NotFound.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: No such resource or endpoint.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
4
api/openapi/components/responses/NotModified.yaml
Normal file
4
api/openapi/components/responses/NotModified.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../headers/ETag.yaml
|
||||
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: Missing, invalid, or expired credentials.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
type: object
|
||||
description: Pagination metadata carried by every list response.
|
||||
required: [total, offset, limit]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of items matching the request, ignoring pagination.
|
||||
offset:
|
||||
type: integer
|
||||
description: Zero-based index of the first returned item.
|
||||
limit:
|
||||
type: integer
|
||||
description: Maximum number of items in this page.
|
||||
35
api/openapi/components/schemas/Problem.yaml
Normal file
35
api/openapi/components/schemas/Problem.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
required: [title, status, code]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: |
|
||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
own semantics get their own URI; switch on `code` instead.
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- unavailable
|
||||
- internal
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
items:
|
||||
$ref: ./ValidationError.yaml
|
||||
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
type: object
|
||||
description: Public server description. Everything an add-server screen needs before login.
|
||||
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable server product name.
|
||||
serverVersion:
|
||||
type: string
|
||||
description: Version of the running server build.
|
||||
specVersion:
|
||||
type: string
|
||||
description: Version of the OpenAPI document this server implements.
|
||||
setupRequired:
|
||||
type: boolean
|
||||
description: True until the first admin user has been created.
|
||||
loginMethods:
|
||||
type: array
|
||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||
items:
|
||||
type: string
|
||||
enum: [password]
|
||||
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
type: object
|
||||
description: One field-level validation failure.
|
||||
required: [field, message]
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
39
api/openapi/openapi.yaml
Normal file
39
api/openapi/openapi.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Navidrome API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||
capability modules through `GET /server` and never sniff versions.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||
disappear without notice, `beta` and `stable` operations only change additively.
|
||||
A level is only ever raised, never lowered.
|
||||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
paths:
|
||||
/server:
|
||||
$ref: ./paths/server.yaml
|
||||
/openapi.json:
|
||||
$ref: ./paths/openapi.yaml#/json
|
||||
/openapi.yaml:
|
||||
$ref: ./paths/openapi.yaml#/yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
42
api/openapi/paths/openapi.yaml
Normal file
42
api/openapi/paths/openapi.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../components/headers/ETag.yaml
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: ../components/responses/NotModified.yaml
|
||||
yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../components/headers/ETag.yaml
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: ../components/responses/NotModified.yaml
|
||||
19
api/openapi/paths/server.yaml
Normal file
19
api/openapi/paths/server.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
get:
|
||||
operationId: getServerInfo
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Describe the server
|
||||
description: |
|
||||
Returns the public server description. No authentication required.
|
||||
Authenticated requests will additionally receive the implemented capability modules
|
||||
once authentication is available.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/ServerInfo.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
1022
cmd/artwork.go
Normal file
1022
cmd/artwork.go
Normal file
File diff suppressed because it is too large
Load diff
1206
cmd/artwork_test.go
Normal file
1206
cmd/artwork_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,9 +2,7 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -31,7 +29,7 @@ func init() {
|
|||
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().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
||||
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||
backupRoot.AddCommand(restoreCommand)
|
||||
|
|
@ -78,24 +76,12 @@ func runBackup(ctx context.Context) {
|
|||
conf.Server.Backup.Path = conf.NewDir(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
|
||||
}
|
||||
requireExistingDB()
|
||||
|
||||
start := time.Now()
|
||||
path, err := db.Backup(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
|
@ -111,36 +97,17 @@ func runPrune(ctx context.Context) {
|
|||
conf.Server.Backup.Count = backupCount
|
||||
}
|
||||
|
||||
if conf.Server.Backup.Count == 0 && !force {
|
||||
fmt.Println("Warning: pruning ALL backups")
|
||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||
var input string
|
||||
_, err := fmt.Scanln(&input)
|
||||
|
||||
if input != "YES" || err != nil {
|
||||
log.Warn("Prune cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||
var path string
|
||||
|
||||
if idx == -1 {
|
||||
path = conf.Server.DbPath
|
||||
} else {
|
||||
path = conf.Server.DbPath[:idx]
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
log.Fatal("No existing database", "path", path)
|
||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
||||
log.Warn("Prune cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
requireExistingDB()
|
||||
|
||||
start := time.Now()
|
||||
count, err := db.Prune(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
|
@ -149,36 +116,29 @@ func runPrune(ctx context.Context) {
|
|||
}
|
||||
|
||||
func runRestore(ctx context.Context) {
|
||||
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||
var path string
|
||||
requireExistingDB()
|
||||
|
||||
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")
|
||||
// A relative --backup-file is resolved against Backup.Path, the same folder
|
||||
// `backup create` writes to. Without this, the value was treated as relative
|
||||
// to the working directory, where the file does not exist.
|
||||
if !filepath.IsAbs(restorePath) {
|
||||
backupPath, err := conf.Server.Backup.Path.Path()
|
||||
if err != nil {
|
||||
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
|
||||
return
|
||||
}
|
||||
restorePath = filepath.Join(backupPath, restorePath)
|
||||
}
|
||||
|
||||
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
||||
log.Warn("Restore cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := db.Restore(ctx, restorePath)
|
||||
if err != nil {
|
||||
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
|
|
|||
99
cmd/doctor.go
Normal file
99
cmd/doctor.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
}
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Check your Navidrome installation for problems",
|
||||
Long: "Run read-only health checks and report what was found. Checks the database for " +
|
||||
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
|
||||
"can fix what it finds. This command never alters your data",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
runDoctor(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
func runDoctor(ctx context.Context) {
|
||||
requireExistingDB()
|
||||
|
||||
healthy := doctor(ctx, db.Db(), os.Stdout)
|
||||
db.Close(ctx)
|
||||
if !healthy {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
|
||||
|
||||
func printFindings(out io.Writer, check, noun string, items []string) {
|
||||
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
|
||||
for _, item := range items {
|
||||
fmt.Fprintln(out, " "+item)
|
||||
}
|
||||
}
|
||||
|
||||
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
|
||||
healthy := true
|
||||
|
||||
fmt.Fprintln(out, "Checking database integrity...")
|
||||
issues, truncated, err := db.IntegrityCheck(ctx, database)
|
||||
switch {
|
||||
case err != nil:
|
||||
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
|
||||
fmt.Fprintln(out, recoveryAdvice)
|
||||
return false
|
||||
case len(issues) == 0:
|
||||
fmt.Fprintln(out, "Integrity check passed.")
|
||||
default:
|
||||
healthy = false
|
||||
printFindings(out, "Integrity check", "issue(s)", issues)
|
||||
switch {
|
||||
case truncated:
|
||||
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
|
||||
fmt.Fprintln(out, recoveryAdvice)
|
||||
case db.IsFTSCorruptionOnly(issues):
|
||||
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
|
||||
default:
|
||||
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
|
||||
fmt.Fprintln(out, recoveryAdvice)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, "Checking foreign keys...")
|
||||
violations, err := db.ForeignKeyCheck(ctx, database)
|
||||
switch {
|
||||
case err != nil:
|
||||
healthy = false
|
||||
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
|
||||
case len(violations) == 0:
|
||||
fmt.Fprintln(out, "Foreign key check passed.")
|
||||
default:
|
||||
healthy = false
|
||||
lines := make([]string, 0, len(violations))
|
||||
for _, v := range violations {
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
|
||||
}
|
||||
printFindings(out, "Foreign key check", "violation(s)", lines)
|
||||
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
|
||||
"in library data; the rest have to be removed by hand.")
|
||||
}
|
||||
|
||||
if healthy {
|
||||
fmt.Fprintln(out, "Database is healthy.")
|
||||
}
|
||||
return healthy
|
||||
}
|
||||
124
cmd/doctor_test.go
Normal file
124
cmd/doctor_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("doctor", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
dbPath string
|
||||
database *sql.DB
|
||||
out *strings.Builder
|
||||
reopen func()
|
||||
)
|
||||
|
||||
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
|
||||
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
|
||||
reopen = func() {
|
||||
var err error
|
||||
database, err = sql.Open(db.Dialect, dbPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
database.SetMaxOpenConns(1)
|
||||
}
|
||||
reopen()
|
||||
DeferCleanup(func() { _ = database.Close() })
|
||||
|
||||
for _, stmt := range []string{
|
||||
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
|
||||
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
|
||||
`create table library(id integer primary key)`,
|
||||
`create table media_file(id integer primary key, library_id integer references library(id))`,
|
||||
} {
|
||||
_, err := database.ExecContext(ctx, stmt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
out = &strings.Builder{}
|
||||
})
|
||||
|
||||
It("reports a healthy database", func() {
|
||||
Expect(doctor(ctx, database, out)).To(BeTrue())
|
||||
Expect(out.String()).To(ContainSubstring("Database is healthy."))
|
||||
})
|
||||
|
||||
It("points to 'search rebuild' when corruption is limited to the search index", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
||||
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
|
||||
})
|
||||
|
||||
It("points to a backup restore when corruption is not limited to the search index", func() {
|
||||
_, err := database.ExecContext(ctx,
|
||||
`insert into library(id)
|
||||
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
|
||||
select x from s`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var rootPage, pageSize int64
|
||||
Expect(database.QueryRowContext(ctx,
|
||||
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
|
||||
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
|
||||
Expect(database.Close()).To(Succeed())
|
||||
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(f.Close()).To(Succeed())
|
||||
reopen()
|
||||
|
||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
||||
})
|
||||
|
||||
It("reports foreign key violations", func() {
|
||||
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
||||
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
|
||||
Expect(out.String()).To(ContainSubstring("media_file"))
|
||||
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
|
||||
// GC never touches player, share or playqueue, so don't promise a full cleanup.
|
||||
Expect(out.String()).To(ContainSubstring("removed by hand"))
|
||||
})
|
||||
|
||||
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
|
||||
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
|
||||
It("does not blame the search index when the issue list is truncated", func() {
|
||||
for _, stmt := range []string{
|
||||
`create table t(a, b)`,
|
||||
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
|
||||
insert into t select x, x + 10000 from s`,
|
||||
`create index media_file_fts_probe on t(a)`,
|
||||
`pragma writable_schema=on`,
|
||||
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
|
||||
where name = 'media_file_fts_probe'`,
|
||||
} {
|
||||
_, err := database.ExecContext(ctx, stmt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
Expect(database.Close()).To(Succeed())
|
||||
reopen()
|
||||
|
||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
||||
})
|
||||
})
|
||||
171
cmd/missing.go
Normal file
171
cmd/missing.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var missingListFormat string
|
||||
|
||||
func init() {
|
||||
missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
||||
missingCmd.AddCommand(missingListCmd)
|
||||
missingCmd.AddCommand(missingFixCmd)
|
||||
rootCmd.AddCommand(missingCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
missingCmd = &cobra.Command{
|
||||
Use: "missing",
|
||||
Short: "Manage missing files",
|
||||
Long: "List files marked as missing and remap them onto existing files",
|
||||
}
|
||||
|
||||
missingListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List missing files",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
runMissingList(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
missingFixCmd = &cobra.Command{
|
||||
Use: "fix <missing path|id> <target path|id>",
|
||||
Short: "Remap a missing file onto an existing file",
|
||||
Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" +
|
||||
"the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" +
|
||||
"a library-relative path, or a libraryID:path pair.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runMissingFix(cmd.Context(), args[0], args[1])
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type displayMissingFile struct {
|
||||
ID string `json:"id"`
|
||||
LibraryID int `json:"libraryId"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func runMissingList(ctx context.Context) {
|
||||
if missingListFormat != "csv" && missingListFormat != "json" {
|
||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat)
|
||||
}
|
||||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
Sort: "path",
|
||||
})
|
||||
if err == nil {
|
||||
err = writeMissingList(os.Stdout, missingListFormat, mfs)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve missing files", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory
|
||||
func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error {
|
||||
if format == "json" {
|
||||
bw := bufio.NewWriter(w)
|
||||
_, _ = io.WriteString(bw, "[")
|
||||
sep := ""
|
||||
for mf, err := range mfs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path})
|
||||
_, _ = fmt.Fprintf(bw, "%s%s", sep, j)
|
||||
sep = ","
|
||||
}
|
||||
_, _ = io.WriteString(bw, "]\n")
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"})
|
||||
for mf, err := range mfs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path})
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
func runMissingFix(ctx context.Context, missingRef, targetRef string) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
missing := resolveMediaFile(ctx, ds, missingRef)
|
||||
target := resolveMediaFile(ctx, ds, targetRef)
|
||||
|
||||
if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil {
|
||||
log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err)
|
||||
}
|
||||
fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path)
|
||||
}
|
||||
|
||||
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
||||
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
||||
mf, err := ds.MediaFile().Get(ctx, ref)
|
||||
if err == nil {
|
||||
return mf
|
||||
}
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
||||
}
|
||||
|
||||
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
||||
}
|
||||
if len(mfs) == 0 {
|
||||
log.Fatal(ctx, "No media file found", "ref", ref)
|
||||
}
|
||||
mfs = preferQualified(ref, mfs)
|
||||
if len(mfs) > 1 {
|
||||
log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs))
|
||||
}
|
||||
return &mfs[0]
|
||||
}
|
||||
|
||||
// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path"
|
||||
// reference both ways: an explicit library wins over a file literally named like one.
|
||||
func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles {
|
||||
id, path, ok := strings.Cut(ref, ":")
|
||||
if !ok {
|
||||
return mfs
|
||||
}
|
||||
libraryID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return mfs
|
||||
}
|
||||
qualified := slice.Filter(mfs, func(mf model.MediaFile) bool {
|
||||
return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path)
|
||||
})
|
||||
if len(qualified) == 0 {
|
||||
return mfs
|
||||
}
|
||||
return qualified
|
||||
}
|
||||
81
cmd/missing_test.go
Normal file
81
cmd/missing_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writeMissingList", func() {
|
||||
cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor {
|
||||
return func(yield func(model.MediaFile, error) bool) {
|
||||
for _, mf := range mfs {
|
||||
if !yield(mf, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
yield(model.MediaFile{}, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"}
|
||||
|
||||
It("writes csv with a header, quoting as needed", func() {
|
||||
var out strings.Builder
|
||||
Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed())
|
||||
Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n"))
|
||||
})
|
||||
|
||||
It("writes a json array", func() {
|
||||
var out strings.Builder
|
||||
Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed())
|
||||
Expect(out.String()).To(MatchJSON(`[
|
||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"},
|
||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}
|
||||
]`))
|
||||
})
|
||||
|
||||
It("writes an empty json array when nothing is missing", func() {
|
||||
var out strings.Builder
|
||||
Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed())
|
||||
Expect(out.String()).To(MatchJSON(`[]`))
|
||||
})
|
||||
|
||||
It("returns the cursor's error", func() {
|
||||
var out strings.Builder
|
||||
Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("preferQualified", func() {
|
||||
target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"}
|
||||
decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"}
|
||||
|
||||
It("picks the library-qualified match over a literal path that looks like one", func() {
|
||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target}))
|
||||
})
|
||||
|
||||
It("picks the named library when the same path exists in two", func() {
|
||||
other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"}
|
||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target}))
|
||||
})
|
||||
|
||||
It("leaves an unqualified reference ambiguous", func() {
|
||||
both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}}
|
||||
Expect(preferQualified("foo.mp3", both)).To(Equal(both))
|
||||
})
|
||||
|
||||
It("leaves it alone when the prefix is not a library id", func() {
|
||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
||||
Expect(preferQualified("x:foo.mp3", both)).To(Equal(both))
|
||||
})
|
||||
|
||||
It("leaves it alone when no candidate matches the qualified form", func() {
|
||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
||||
Expect(preferQualified("9:nope.mp3", both)).To(Equal(both))
|
||||
})
|
||||
})
|
||||
27
cmd/pls.go
27
cmd/pls.go
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
|
@ -108,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
|||
}
|
||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||
}
|
||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||
}
|
||||
|
|
@ -116,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
|||
}
|
||||
|
||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
||||
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
if len(playlists) > 0 {
|
||||
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
|
|
@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
|
|||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
pls := playlist.ToM3U8()
|
||||
if outputFile == "-" || outputFile == "" {
|
||||
println(pls)
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
}
|
||||
|
||||
func writePlaylist(m3u string, out io.Writer, file string) {
|
||||
if file == "" || file == "-" {
|
||||
fmt.Fprint(out, m3u)
|
||||
return
|
||||
}
|
||||
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +160,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
if playlistID != "" && outputFile == "" {
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
println(playlist.ToM3U8())
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +194,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
exported := 0
|
||||
for _, pls := range allPls {
|
||||
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
||||
if err != nil {
|
||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||
continue
|
||||
|
|
|
|||
35
cmd/pls_test.go
Normal file
35
cmd/pls_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writePlaylist", func() {
|
||||
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
|
||||
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(func() { _ = os.Remove(plsFile) })
|
||||
})
|
||||
|
||||
DescribeTable("writes the playlist to exactly one destination",
|
||||
func(file, wantStream, wantFile string) {
|
||||
var out strings.Builder
|
||||
|
||||
writePlaylist(m3u, &out, file)
|
||||
|
||||
written, _ := os.ReadFile(plsFile)
|
||||
Expect(out.String()).To(Equal(wantStream))
|
||||
Expect(string(written)).To(Equal(wantFile))
|
||||
},
|
||||
Entry("no file name writes to the stream", "", m3u, ""),
|
||||
Entry("a dash writes to the stream", "-", m3u, ""),
|
||||
Entry("a path writes to the file", plsFile, "", m3u),
|
||||
)
|
||||
})
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -244,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -265,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -314,7 +313,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
|||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
w := newTabWriter(&sb)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
|
|
@ -330,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
|||
func runPluginList(ctx context.Context) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
list, err := ds.Plugin().GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
|
|
@ -373,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
ds, ctx := getAdminContext(cmd.Context())
|
||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||
cur, err := ds.Plugin().Get(ctx, args[0])
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
|
|
|
|||
115
cmd/root.go
115
cmd/root.go
|
|
@ -2,6 +2,7 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
|
@ -43,7 +44,9 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
|||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runNavidrome(cmd.Context())
|
||||
if err := runNavidrome(cmd.Context()); err != nil {
|
||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
|
|
@ -75,16 +78,17 @@ 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(ctx context.Context) {
|
||||
defer db.Init(ctx)()
|
||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
||||
func runNavidrome(parentCtx context.Context) error {
|
||||
defer db.Init(parentCtx)()
|
||||
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g, ctx := errgroup.WithContext(parentCtx)
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(startJellyfinDiscovery(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
|
|
@ -100,9 +104,11 @@ func runNavidrome(ctx context.Context) {
|
|||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
// Errors caused by a normal shutdown are not failures
|
||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||
|
|
@ -131,6 +137,9 @@ func startServer(ctx context.Context) func() error {
|
|||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.DevAPIv1 {
|
||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
|
|
@ -138,7 +147,7 @@ func startServer(ctx context.Context) func() error {
|
|||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
||||
}
|
||||
if conf.Server.DevEnableProfiler {
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
a.MountRouter("Profiling", "/debug", profilerHandler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
|
|
@ -147,6 +156,14 @@ func startServer(ctx context.Context) func() error {
|
|||
}
|
||||
}
|
||||
|
||||
// profilerHandler returns the pprof handler. net/http/pprof resolves the profile
|
||||
// name from the raw request path, so the BasePath has to come off first.
|
||||
func profilerHandler() http.Handler {
|
||||
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
|
||||
basePath := strings.TrimRight(conf.Server.BasePath, "/")
|
||||
return http.StripPrefix(basePath, middleware.Profiler())
|
||||
}
|
||||
|
||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
|
|
@ -174,11 +191,11 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
|||
}
|
||||
|
||||
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||
pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||
pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -189,11 +206,11 @@ func pidHashChanged(ds model.DataStore) (bool, error) {
|
|||
func runInitialScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
ds := CreateDataStore()
|
||||
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -209,7 +226,7 @@ func runInitialScan(ctx context.Context) func() error {
|
|||
switch {
|
||||
case fullScanRequired == "1":
|
||||
log.Warn(ctx, "Full scan required after migration")
|
||||
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||
case pidHasChanged:
|
||||
log.Warn(ctx, "PID config changed, performing full scan")
|
||||
fullScanRequired = "1"
|
||||
|
|
@ -334,6 +351,18 @@ func startInsightsCollector(ctx context.Context) func() error {
|
|||
}
|
||||
}
|
||||
|
||||
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
|
||||
func startJellyfinDiscovery(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
|
||||
log.Debug("Jellyfin auto-discovery is DISABLED")
|
||||
return nil
|
||||
}
|
||||
CreateJellyfinDiscovery().Serve(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||
// It is responsible for the Jukebox functionality
|
||||
func startPlaybackServer(ctx context.Context) func() error {
|
||||
|
|
@ -353,58 +382,50 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting artwork worker")
|
||||
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
|
||||
worker.PauseWhile(scanner.IsScanning)
|
||||
return worker.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
|
||||
// recurring stale-absent recheck and prune jobs.
|
||||
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
|
||||
// until it ends; both write to the DB, and competing for the lock can make either fail.
|
||||
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
if !ok {
|
||||
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
if err := run(ctx); err != nil {
|
||||
log.Error(ctx, "Error running "+job, err)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
||||
// reports an artwork config change without acting on it.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
|
||||
if err := worker.EnqueueStaleAbsentAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
|
||||
}
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
|
||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||
}
|
||||
|
||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
||||
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||
}
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
|
||||
backfilled, err := worker.Backfill(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running artwork backfill", err)
|
||||
return nil
|
||||
}
|
||||
if !backfilled {
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune")
|
||||
timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running post-backfill artwork prune", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
46
cmd/root_test.go
Normal file
46
cmd/root_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"runtime/pprof"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = pprof.NewProfile("nd-profiler-test")
|
||||
|
||||
var _ = Describe("profilerHandler", func() {
|
||||
// Mirrors how server.MountRouter mounts the handler.
|
||||
mount := func() http.Handler {
|
||||
router := chi.NewRouter()
|
||||
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
|
||||
return router
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
DescribeTable("serves a named profile",
|
||||
func(basePath string) {
|
||||
conf.Server.BasePath = basePath
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
|
||||
mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0"))
|
||||
},
|
||||
Entry("without a BasePath", ""),
|
||||
Entry("with a BasePath", "/music"),
|
||||
Entry("with a root BasePath", "/"),
|
||||
Entry("with a trailing-slash BasePath", "/music/"),
|
||||
)
|
||||
})
|
||||
55
cmd/search.go
Normal file
55
cmd/search.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var searchRebuildForce bool
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(searchRoot)
|
||||
|
||||
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
|
||||
searchRoot.AddCommand(searchRebuildCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
searchRoot = &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search index maintenance",
|
||||
}
|
||||
|
||||
searchRebuildCmd = &cobra.Command{
|
||||
Use: "rebuild",
|
||||
Short: "Rebuild the full-text search index",
|
||||
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
|
||||
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
|
||||
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
|
||||
"library. This must be done offline",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
runSearchRebuild(cmd.Context())
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func runSearchRebuild(ctx context.Context) {
|
||||
requireExistingDB()
|
||||
|
||||
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
|
||||
log.Warn("Rebuild cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Rebuilding the search index...")
|
||||
err := db.RebuildFTS(ctx, db.Db())
|
||||
db.Close(ctx)
|
||||
if err != nil {
|
||||
log.Fatal("Error rebuilding the search index", err)
|
||||
}
|
||||
fmt.Println("Search index rebuilt successfully.")
|
||||
}
|
||||
11
cmd/svc.go
11
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
type svcControl struct {
|
||||
ctx context.Context
|
||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
|
@ -53,8 +53,13 @@ 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)
|
||||
err := runNavidrome(p.ctx)
|
||||
close(p.done)
|
||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
||||
// service manager see the failure and restart the service
|
||||
if err != nil {
|
||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -74,7 +79,7 @@ func (p *svcControl) Stop(service.Service) error {
|
|||
var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options := make(service.KeyValue)
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["SuccessExitStatus"] = "SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["SystemdScript"] = systemdScript
|
||||
|
|
|
|||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
|||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
||||
if existingUser != nil {
|
||||
return fmt.Errorf("existing user '%s'", userID)
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
|||
}
|
||||
|
||||
if len(libraryIds) > 0 && !setAdmin {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
|||
return libraryError(user.Libraries)
|
||||
}
|
||||
} else {
|
||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.User(ctx).Put(&user)
|
||||
err = tx.User().Put(ctx, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
var user *model.User
|
||||
|
||||
err = ds.WithTx(func(tx model.DataStore) error {
|
||||
count, err := tx.User(ctx).CountAll()
|
||||
count, err := tx.User().CountAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
return err
|
||||
}
|
||||
|
||||
return tx.User(ctx).Delete(user.ID)
|
||||
return tx.User().Delete(ctx, user.ID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
if len(libraryIds) > 0 && !setAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
if setAdmin && !user.IsAdmin {
|
||||
libraries, err := tx.Library(ctx).GetAll()
|
||||
libraries, err := tx.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
|||
return nil
|
||||
}
|
||||
|
||||
err := tx.User(ctx).Put(user)
|
||||
err := tx.User().Put(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -393,13 +393,11 @@ func runUserList(ctx context.Context) {
|
|||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
users, err := ds.User(ctx).ReadAll()
|
||||
userList, err := ds.User().ReadAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||
}
|
||||
|
||||
userList := users.(model.Users)
|
||||
|
||||
if outputFormat == "csv" {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
_ = w.Write([]string{
|
||||
|
|
|
|||
31
cmd/utils.go
31
cmd/utils.go
|
|
@ -4,7 +4,12 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
|
@ -13,6 +18,28 @@ import (
|
|||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// requireExistingDB aborts the command when the database file (DbPath minus DSN
|
||||
// params) does not exist.
|
||||
func requireExistingDB() {
|
||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
log.Fatal("No existing database", "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
func confirmYES(warning string) bool {
|
||||
fmt.Println(warning)
|
||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||
var input string
|
||||
_, err := fmt.Scanln(&input)
|
||||
return input == "YES" && err == nil
|
||||
}
|
||||
|
||||
// newTabWriter keeps every CLI table on the same column settings.
|
||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
}
|
||||
|
||||
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||
sqlDB := db.Db()
|
||||
ds := persistence.New(sqlDB)
|
||||
|
|
@ -25,14 +52,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
|||
}
|
||||
|
||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||
user, err := ds.User(ctx).FindByUsername(id)
|
||||
user, err := ds.User().FindByUsername(ctx, id)
|
||||
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
user, err = ds.User(ctx).Get(id)
|
||||
user, err = ds.User().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -30,6 +31,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -70,13 +72,17 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||
insights := metrics.GetInstance(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
quickConnect := quickconnect.GetInstance()
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -90,17 +96,17 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
|
|
@ -129,10 +135,18 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
|
||||
quickConnect := quickconnect.GetInstance()
|
||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := apiv1.New(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +160,7 @@ func CreatePublicRouter() *public.Router {
|
|||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
return router
|
||||
}
|
||||
|
|
@ -165,6 +179,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
return router
|
||||
}
|
||||
|
||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
discovery := jellyfin.NewDiscovery(dataStore)
|
||||
return discovery
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -186,7 +207,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +218,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
|
|
@ -223,6 +244,18 @@ func CreateArtworkWorker() *artwork.Worker {
|
|||
return worker
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
fFmpeg := ffmpeg.New()
|
||||
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
|
||||
return tracingResolver
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -234,7 +267,7 @@ func getPluginManager() *plugins.Manager {
|
|||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -36,13 +37,15 @@ var allProviders = wire.NewSet(
|
|||
server.New,
|
||||
subsonic.New,
|
||||
jellyfin.New,
|
||||
jellyfin.NewDiscovery,
|
||||
apiv1.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.GetInstance,
|
||||
scanner.GetWatcher,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
|
|
@ -90,6 +93,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
@ -108,6 +117,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
@ -144,6 +159,13 @@ func CreateArtworkWorker() *artwork.Worker {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
artwork.NewTracingResolver,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -72,6 +73,7 @@ type configOptions struct {
|
|||
Matcher matcherOptions `json:",omitzero"`
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
EnableNaturalSorting bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
FFmpegPath string
|
||||
|
|
@ -90,6 +92,7 @@ type configOptions struct {
|
|||
EnableUserEditing bool
|
||||
EnableArtworkUpload bool
|
||||
MaxImageUploadSize string
|
||||
MaxImageSize string
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
|
|
@ -158,6 +161,7 @@ type configOptions struct {
|
|||
DevExternalArtistFetchMultiplier float64
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevAPIv1 bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
|
@ -232,6 +236,8 @@ type jellyfinOptions struct {
|
|||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||
ExposedPublicUsers string
|
||||
AutoDiscovery bool
|
||||
QuickConnect bool
|
||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||
|
|
@ -312,6 +318,12 @@ var currentGOOS = func() string {
|
|||
return runtime.GOOS
|
||||
}
|
||||
|
||||
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
|
||||
// so callers cannot infer it from the certificate alone.
|
||||
func (c *configOptions) TLSEnabled() bool {
|
||||
return c.TLSCert != "" && c.TLSKey != ""
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
|
|
@ -342,6 +354,13 @@ func LoadFromFile(confFile string) {
|
|||
Load(true)
|
||||
}
|
||||
|
||||
func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) {
|
||||
if val.Nanoseconds() < 0 {
|
||||
log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original)
|
||||
*val = original
|
||||
}
|
||||
}
|
||||
|
||||
func Load(noConfigDump bool) {
|
||||
parseIniFileConfiguration()
|
||||
remapEnvVarKeysFromConfig()
|
||||
|
|
@ -391,7 +410,7 @@ func Load(noConfigDump bool) {
|
|||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||
}
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
}
|
||||
|
|
@ -409,6 +428,20 @@ func Load(noConfigDump bool) {
|
|||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.SetRedacting(Server.EnableLogRedacting)
|
||||
|
||||
durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout)
|
||||
durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
|
||||
durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration)
|
||||
durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
|
||||
durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength)
|
||||
durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait)
|
||||
|
||||
durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
|
||||
durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
|
||||
durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
|
||||
durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
|
||||
durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay)
|
||||
durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
|
||||
|
||||
// Log deprecated, removed and unknown options
|
||||
for _, o := range deprecatedOptions {
|
||||
logDeprecatedOptions(o.name, o.replacement)
|
||||
|
|
@ -421,7 +454,8 @@ func Load(noConfigDump bool) {
|
|||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
validateMaxImageUploadSize,
|
||||
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
|
||||
validateByteSize("MaxImageSize", Server.MaxImageSize),
|
||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -481,6 +515,19 @@ func Load(noConfigDump bool) {
|
|||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
|
||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||
if imgSize < uploadSize {
|
||||
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
|
||||
Server.MaxImageSize = Server.MaxImageUploadSize
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
|
|
@ -806,11 +853,20 @@ func validatePurgeMissingOption() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateMaxImageUploadSize() error {
|
||||
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
|
||||
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
|
||||
func validateByteSize(name, value string) func() error {
|
||||
return func() error {
|
||||
size, err := humanize.ParseBytes(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
|
||||
}
|
||||
if size == 0 {
|
||||
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
|
||||
}
|
||||
if size > math.MaxInt64 {
|
||||
return fmt.Errorf("invalid %s %q: value is too large", name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEnforceNonRootUser() error {
|
||||
|
|
@ -940,7 +996,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
||||
viper.SetDefault("playlistspath", "")
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enablem3uexternalalbumart", false)
|
||||
|
|
@ -953,6 +1009,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("enablenaturalsorting", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
|
|
@ -980,16 +1037,17 @@ func setViperDefaults() {
|
|||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
||||
viper.SetDefault("enablesharing", true)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
viper.SetDefault("enablescheduleddbanalyze", true)
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||
viper.SetDefault("extauth.trustedsources", "")
|
||||
|
|
@ -1037,6 +1095,8 @@ func setViperDefaults() {
|
|||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("jellyfin.enabled", false)
|
||||
viper.SetDefault("jellyfin.servername", "")
|
||||
viper.SetDefault("jellyfin.autodiscovery", false)
|
||||
viper.SetDefault("jellyfin.quickconnect", true)
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
|
|
@ -1065,6 +1125,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devapiv1", false)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -326,6 +330,21 @@ var _ = Describe("Configuration", func() {
|
|||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
||||
})
|
||||
|
||||
It("creates the log file readable only by the owner", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("file modes are not enforced on Windows")
|
||||
}
|
||||
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("logfile", logFile)
|
||||
DeferCleanup(log.SetOutput, os.Stderr)
|
||||
conf.Load(true)
|
||||
|
||||
info, err := os.Stat(logFile)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
|
||||
})
|
||||
|
||||
It("is called when BaseURL is invalid", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("baseurl", "://invalid")
|
||||
|
|
@ -336,19 +355,10 @@ var _ = Describe("Configuration", func() {
|
|||
|
||||
})
|
||||
|
||||
Describe("ValidateMaxImageUploadSize", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
Describe("ValidateByteSize", func() {
|
||||
DescribeTable("accepts valid size values",
|
||||
func(input string) {
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
|
||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
|
||||
},
|
||||
Entry("megabytes", "10MB"),
|
||||
Entry("gigabytes", "1GB"),
|
||||
|
|
@ -359,14 +369,60 @@ var _ = Describe("Configuration", func() {
|
|||
|
||||
DescribeTable("rejects invalid size values",
|
||||
func(input string) {
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
|
||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
|
||||
},
|
||||
Entry("garbage string", "not-a-size"),
|
||||
Entry("negative-looking", "-10MB"),
|
||||
Entry("zero", "0"),
|
||||
Entry("zero with unit", "0MB"),
|
||||
Entry("overflows int64", "9223372036854775808"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("MaxImageSize floor", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("is raised to MaxImageUploadSize when configured lower", func() {
|
||||
viper.SetDefault("maximagesize", "5MB")
|
||||
viper.SetDefault("maximageuploadsize", "50MB")
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
|
||||
})
|
||||
|
||||
It("keeps a larger MaxImageSize unchanged", func() {
|
||||
viper.SetDefault("maximagesize", "30MB")
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.MaxImageSize).To(Equal("30MB"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scanner.Extractor", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
It("falls back to taglib for an unknown extractor", func() {
|
||||
viper.SetDefault("scanner.extractor", "ffmpeg")
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||
})
|
||||
|
||||
It("keeps taglib", func() {
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnforceNonRootUser", func() {
|
||||
It("defaults to false", func() {
|
||||
conf.Load(true)
|
||||
|
|
@ -436,4 +492,73 @@ var _ = Describe("Configuration", func() {
|
|||
Entry("INI format", "ini"),
|
||||
Entry("JSON format", "json"),
|
||||
)
|
||||
|
||||
It("should use default values for negative duration fields", func() {
|
||||
filename := filepath.Join("testdata", "invalid_duration.toml")
|
||||
conf.InitConfig(filename, false)
|
||||
conf.Load(true)
|
||||
|
||||
server := conf.Server
|
||||
Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout))
|
||||
Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh))
|
||||
Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration))
|
||||
Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval))
|
||||
Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength))
|
||||
Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait))
|
||||
|
||||
Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate))
|
||||
Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout))
|
||||
Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive))
|
||||
Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive))
|
||||
Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay))
|
||||
Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout))
|
||||
})
|
||||
|
||||
It("should use parsed values for duration fields", func() {
|
||||
conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false)
|
||||
conf.Load(true)
|
||||
|
||||
configured := 1 * time.Second
|
||||
|
||||
server := conf.Server
|
||||
Expect(server.SessionTimeout).To(Equal(configured))
|
||||
Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured))
|
||||
Expect(server.DefaultShareExpiration).To(Equal(configured))
|
||||
Expect(server.UIPlaybackReportInterval).To(Equal(configured))
|
||||
Expect(server.AuthWindowLength).To(Equal(configured))
|
||||
Expect(server.Scanner.WatcherWait).To(Equal(configured))
|
||||
|
||||
Expect(server.DevActivityPanelUpdateRate).To(Equal(configured))
|
||||
Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured))
|
||||
Expect(server.DevArtistInfoTimeToLive).To(Equal(configured))
|
||||
Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured))
|
||||
Expect(server.DevInsightsInitialDelay).To(Equal(configured))
|
||||
Expect(server.DevPluginCompilationTimeout).To(Equal(configured))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("TLSEnabled", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("is false when neither the certificate nor the key is set", func() {
|
||||
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is true when both the certificate and the key are set", func() {
|
||||
conf.Server.TLSCert = "cert.pem"
|
||||
conf.Server.TLSKey = "key.pem"
|
||||
Expect(conf.Server.TLSEnabled()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is false when only the certificate is set", func() {
|
||||
conf.Server.TLSCert = "cert.pem"
|
||||
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is false when only the key is set", func() {
|
||||
conf.Server.TLSKey = "key.pem"
|
||||
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend
|
|||
|
||||
var ToPascalCase = toPascalCase
|
||||
|
||||
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
|
||||
var ValidateByteSize = validateByteSize
|
||||
|
||||
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
||||
oldGOOS := currentGOOS
|
||||
|
|
|
|||
12
conf/testdata/invalid_duration.toml
vendored
Normal file
12
conf/testdata/invalid_duration.toml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
SessionTimeout = "-10s"
|
||||
SmartPlaylistRefreshDelay = "-10s"
|
||||
UIPlaybackReportInterval = "-10s"
|
||||
AuthWindowLength = "-10s"
|
||||
DefaultShareExpiration = "-10s"
|
||||
Scanner.WatcherWait = "-10s"
|
||||
DevActivityPanelUpdateRate = "-10s"
|
||||
DevArtworkThrottleBacklogTimeout = "-10s"
|
||||
DevArtistInfoTimeToLive = "-10s"
|
||||
DevAlbumInfoTimeToLive = "-10s"
|
||||
DevInsightsInitialDelay = "-10s"
|
||||
DevPluginCompilationTimeout = "-10s"
|
||||
12
conf/testdata/valid_duration.toml
vendored
Normal file
12
conf/testdata/valid_duration.toml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
SessionTimeout = "1s"
|
||||
SmartPlaylistRefreshDelay = "1s"
|
||||
UIPlaybackReportInterval = "1s"
|
||||
AuthWindowLength = "1s"
|
||||
DefaultShareExpiration = "1s"
|
||||
Scanner.WatcherWait = "1s"
|
||||
DevActivityPanelUpdateRate = "1s"
|
||||
DevArtworkThrottleBacklogTimeout = "1s"
|
||||
DevArtistInfoTimeToLive = "1s"
|
||||
DevAlbumInfoTimeToLive = "1s"
|
||||
DevInsightsInitialDelay = "1s"
|
||||
DevPluginCompilationTimeout = "1s"
|
||||
|
|
@ -24,8 +24,8 @@ const (
|
|||
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
|
||||
DBAnalyzePendingKey = "DBAnalyzePending"
|
||||
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
|
||||
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against
|
||||
// to detect artwork-affecting config changes across restarts.
|
||||
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check
|
||||
// compares against to detect artwork-affecting config changes across restarts.
|
||||
ArtConfFingerprintPropertyKey = "ArtConfFingerprint"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
|
|
@ -34,20 +34,22 @@ const (
|
|||
JWTPublicSecretKey = "JWTPublicSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
DefaultSmartRefresh = 5 * time.Second
|
||||
DefaultShareExpiration = 8760 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
|
||||
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
ArtworkPostBackfillPruneDelay = 10 * time.Minute
|
||||
ArtworkEnqueueMissingSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
|
||||
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
|
||||
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
|
||||
DefaultEncryptionKey = "just for obfuscation"
|
||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||
APIKeyPrefix = "nds_"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
|
@ -58,6 +60,7 @@ const (
|
|||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
URLPathAPIv1 = "/api/v1"
|
||||
|
||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||
|
|
@ -72,6 +75,7 @@ const (
|
|||
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
|
||||
DefaultMaxSidebarPlaylists = 100
|
||||
|
||||
DefaultAuthWindowLength = 20 * time.Second
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
|
|
@ -107,11 +111,15 @@ const (
|
|||
DefaultScannerExtractor = "taglib"
|
||||
DefaultWatcherWait = 5 * time.Second
|
||||
Zwsp = string('\u200b')
|
||||
|
||||
DefaultActivityPanelUpdateRate = 300 * time.Millisecond
|
||||
DefaultPluginCompilationTimeout = time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultUICoverArtSize = 300
|
||||
DefaultMaxImageUploadSize = "10MB"
|
||||
DefaultMaxImageSize = "20MB"
|
||||
)
|
||||
|
||||
// Prometheus options
|
||||
|
|
@ -200,7 +208,7 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
var HTTPUserAgent = "Navidrome" + "/" + Version
|
||||
var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallFilter=setrlimit
|
||||
SystemCallFilter=setrlimit mbind
|
||||
SystemCallArchitectures=native
|
||||
UMask=0066
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -22,11 +26,43 @@ type PluginLoader interface {
|
|||
LoadMediaAgent(name string) (Interface, bool)
|
||||
}
|
||||
|
||||
// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific
|
||||
// RetryIn duration.
|
||||
const agentCooldown = time.Minute
|
||||
|
||||
// errUnsupported marks an agent that does not implement the requested method: it never ran,
|
||||
// so it neither answered nor throttled.
|
||||
var errUnsupported = errors.New("agent does not support this method")
|
||||
|
||||
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
|
||||
// until one returns valid data.
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
cooldowns cooldowns
|
||||
}
|
||||
|
||||
// cooldowns remembers, across dispatches, which agents asked to be left alone and until when.
|
||||
type cooldowns struct {
|
||||
mu sync.RWMutex
|
||||
until map[string]time.Time
|
||||
}
|
||||
|
||||
func (c *cooldowns) active(name string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return time.Now().Before(c.until[name])
|
||||
}
|
||||
|
||||
// park keeps whichever deadline is later, so a call still in flight when a longer cooldown
|
||||
// starts cannot cut it short when it finally answers.
|
||||
func (c *cooldowns) park(name string, d time.Duration) {
|
||||
until := time.Now().Add(d)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if until.After(c.until[name]) {
|
||||
c.until[name] = until
|
||||
}
|
||||
}
|
||||
|
||||
// GetAgents returns the singleton instance of Agents
|
||||
|
|
@ -41,6 +77,7 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
|||
return &Agents{
|
||||
ds: ds,
|
||||
pluginLoader: pluginLoader,
|
||||
cooldowns: cooldowns{until: map[string]time.Time{}},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,12 +127,19 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
|||
} else if isPlugin {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
||||
} else {
|
||||
log.Debug("Unknown agent ignored", "name", name)
|
||||
log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins))
|
||||
}
|
||||
}
|
||||
return validAgents
|
||||
}
|
||||
|
||||
// availableAgentNames returns every name accepted by the Agents config option.
|
||||
func availableAgentNames(plugins []string) []string {
|
||||
names := append(slices.Collect(maps.Keys(Map)), plugins...)
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (a *Agents) getAgent(ea enabledAgent) Interface {
|
||||
if ea.isPlugin {
|
||||
// Try to load WASM plugin agent (if plugin loader is available)
|
||||
|
|
@ -171,7 +215,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
|||
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
return "", errUnsupported
|
||||
}
|
||||
return retriever.GetArtistMBID(ctx, id, name)
|
||||
})
|
||||
|
|
@ -188,7 +232,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
|||
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
return "", errUnsupported
|
||||
}
|
||||
return retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
})
|
||||
|
|
@ -205,7 +249,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
|||
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
return "", ErrNotFound
|
||||
return "", errUnsupported
|
||||
}
|
||||
return retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
})
|
||||
|
|
@ -224,7 +268,11 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
attempts := newAttempts(&a.cooldowns)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
if attempts.skip(enabledAgent.name) {
|
||||
continue
|
||||
}
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
|
|
@ -237,6 +285,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
continue
|
||||
}
|
||||
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
|
||||
attempts.record(enabledAgent.name, err)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
|
|
@ -246,7 +295,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
return similar, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
return nil, attempts.noResultErr()
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
|
|
@ -260,7 +309,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
|||
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
})
|
||||
|
|
@ -281,7 +330,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
|||
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
||||
})
|
||||
|
|
@ -295,7 +344,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
|||
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
|
||||
retriever, ok := ag.(AlbumInfoRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
})
|
||||
|
|
@ -309,7 +358,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
|||
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(AlbumImageRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
})
|
||||
|
|
@ -320,7 +369,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m
|
|||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByTrackRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
|
|
@ -331,7 +380,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m
|
|||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
|
|
@ -349,16 +398,61 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str
|
|||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByArtistRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
return nil, errUnsupported
|
||||
}
|
||||
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
|
||||
})
|
||||
}
|
||||
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
// agentAttempts tallies what the enabled agents did in one dispatch.
|
||||
type agentAttempts struct {
|
||||
cooldowns *cooldowns
|
||||
throttled bool
|
||||
answered bool
|
||||
}
|
||||
|
||||
func newAttempts(c *cooldowns) agentAttempts {
|
||||
return agentAttempts{cooldowns: c}
|
||||
}
|
||||
|
||||
// skip reports whether name is still cooling down, counting it as throttled for this dispatch.
|
||||
func (t *agentAttempts) skip(name string) bool {
|
||||
if !t.cooldowns.active(name) {
|
||||
return false
|
||||
}
|
||||
t.throttled = true
|
||||
return true
|
||||
}
|
||||
|
||||
// record files one agent's outcome, parking it when it asked to be retried later.
|
||||
func (t *agentAttempts) record(name string, err error) {
|
||||
switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); {
|
||||
case errors.Is(err, errUnsupported):
|
||||
case isRetryLater:
|
||||
t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown))
|
||||
t.throttled = true
|
||||
default:
|
||||
t.answered = true
|
||||
}
|
||||
}
|
||||
|
||||
// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss.
|
||||
func (t *agentAttempts) noResultErr() error {
|
||||
if t.throttled && !t.answered {
|
||||
return ErrRetryLater
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// callAgent tries each enabled agent in order until found reports a usable result.
|
||||
func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) {
|
||||
var zero T
|
||||
start := time.Now()
|
||||
attempts := newAttempts(&agents.cooldowns)
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
if attempts.skip(enabledAgent.name) {
|
||||
continue
|
||||
}
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
|
|
@ -367,41 +461,29 @@ func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodNa
|
|||
break
|
||||
}
|
||||
result, err := fn(ag)
|
||||
attempts.record(enabledAgent.name, err)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if result != zero {
|
||||
if found(result) {
|
||||
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return zero, ErrNotFound
|
||||
return zero, attempts.noResultErr()
|
||||
}
|
||||
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
return callAgent(ctx, agents, methodName, fn, func(result T) bool {
|
||||
var zero T
|
||||
return result != zero
|
||||
})
|
||||
}
|
||||
|
||||
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
results, err := fn(ag)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 })
|
||||
}
|
||||
|
||||
var _ Interface = (*Agents)(nil)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package agents
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -14,6 +16,29 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("cooldowns", func() {
|
||||
// Calls to one agent overlap, so a short cooldown can land after a long one started.
|
||||
It("keeps the longer deadline when a shorter park lands after it", func() {
|
||||
c := cooldowns{until: map[string]time.Time{}}
|
||||
|
||||
c.park("fake", time.Hour)
|
||||
c.park("fake", time.Millisecond)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
Expect(c.active("fake")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("extends the deadline when the later park is longer", func() {
|
||||
c := cooldowns{until: map[string]time.Time{}}
|
||||
|
||||
c.park("fake", time.Millisecond)
|
||||
c.park("fake", time.Hour)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
Expect(c.active("fake")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Agents", func() {
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
|
|
@ -34,10 +59,10 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
|
||||
It("calls the placeholder GetArtistImages", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
|
||||
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
|
||||
Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -67,6 +92,22 @@ var _ = Describe("Agents", func() {
|
|||
Expect(ags).ToNot(ContainElement("disabled"))
|
||||
})
|
||||
|
||||
Describe("availableAgentNames", func() {
|
||||
It("combines built-in agents with the given plugins", func() {
|
||||
names := availableAgentNames([]string{"apple-music"})
|
||||
Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty"))
|
||||
})
|
||||
|
||||
It("returns the names sorted", func() {
|
||||
names := availableAgentNames([]string{"zz-plugin", "aa-plugin"})
|
||||
Expect(slices.IsSorted(names)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("works when there are no plugins", func() {
|
||||
Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
|
|
@ -160,6 +201,102 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("cooldown", func() {
|
||||
It("skips an agent that returned RetryLaterError until the deadline", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
// Immediately after: agent is skipped, not called
|
||||
mock.Err = nil
|
||||
calls := mock.Calls
|
||||
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls))
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
// Providers that throttle without saying for how long (Last.fm sends no delay at all)
|
||||
// must still be parked, or the aggregate keeps calling them on every request.
|
||||
It("parks an agent that asked to be retried without a delay", func() {
|
||||
mock.Err = ErrRetryLater
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
mock.Err = nil
|
||||
calls := mock.Calls
|
||||
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("calls the agent again once the cooldown expires", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond}
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
mock.Err = nil
|
||||
Eventually(func() (string, error) {
|
||||
return ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
}, 5*time.Second, 10*time.Millisecond).Should(Equal("bio"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() {
|
||||
mock.Err = errors.New("boom")
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
// ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive
|
||||
// answer from any other agent is an answer, throttled peer or not.
|
||||
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
||||
other := &mockAgent{Err: ErrNotFound}
|
||||
Register("fake2", func(model.DataStore) Interface { return other })
|
||||
conf.Server.Agents = "fake,fake2"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
|
||||
// The cooldown was still recorded for the throttled agent
|
||||
calls := mock.Calls
|
||||
_, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
||||
empty := &testImageAgent{Name: "emptyImages"}
|
||||
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
||||
conf.Server.Agents = "fake,emptyImages"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
||||
other := &mockAgent{Err: ErrNotFound}
|
||||
Register("fake2", func(model.DataStore) Interface { return other })
|
||||
conf.Server.Agents = "fake,fake2"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
|
||||
|
|
@ -423,8 +560,9 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []any
|
||||
Err error
|
||||
Args []any
|
||||
Err error
|
||||
Calls int
|
||||
}
|
||||
|
||||
func (a *mockAgent) AgentName() string {
|
||||
|
|
@ -449,6 +587,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
|
|||
|
||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []any{id, name, mbid}
|
||||
a.Calls++
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package agents
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -52,9 +55,49 @@ func (s Song) Equals(other Song) bool {
|
|||
return h1 == h2
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
// ErrNotFound means the provider answered and had nothing. Return the underlying error
|
||||
// for a fault instead, or callers that back off on faults will treat it as definitive.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable
|
||||
// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and
|
||||
// errors.AsType[*RetryLaterError] match it and every delay-carrying variant.
|
||||
// Treat it as immutable; build a new RetryLaterError to name a delay.
|
||||
var ErrRetryLater = &RetryLaterError{}
|
||||
|
||||
// RetryLaterError asks callers to back off, optionally for the delay the provider requested.
|
||||
type RetryLaterError struct {
|
||||
RetryIn time.Duration
|
||||
}
|
||||
|
||||
func (e *RetryLaterError) Error() string {
|
||||
if e.RetryIn > 0 {
|
||||
return fmt.Sprintf("retry later (in %s)", e.RetryIn)
|
||||
}
|
||||
return "retry later"
|
||||
}
|
||||
|
||||
func (e *RetryLaterError) Is(target error) bool {
|
||||
_, ok := target.(*RetryLaterError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely.
|
||||
const MaxRetryIn = time.Hour
|
||||
const maxRetryInSeconds = int(MaxRetryIn / time.Second)
|
||||
|
||||
// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token.
|
||||
// Anything unparseable or non-positive means unspecified.
|
||||
func ParseRetryIn(seconds string) time.Duration {
|
||||
// Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds,
|
||||
// turning "wait an age" into a fraction of a second. Parse at a fixed width so the
|
||||
// cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first.
|
||||
secs, err := strconv.ParseInt(seconds, 10, 64)
|
||||
if err != nil || secs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second
|
||||
}
|
||||
|
||||
// AlbumInfoRetriever provides album info (no images)
|
||||
type AlbumInfoRetriever interface {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,42 @@
|
|||
package agents
|
||||
package agents_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Song.Equals", func() {
|
||||
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
|
||||
It("true for identical songs incl Artists", func() {
|
||||
Expect(base.Equals(base)).To(BeTrue())
|
||||
var _ = Describe("RetryLaterError", func() {
|
||||
It("matches the ErrRetryLater sentinel via errors.Is", func() {
|
||||
err := &agents.RetryLaterError{RetryIn: 30 * time.Second}
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
It("false when Artists differ", func() {
|
||||
other := base
|
||||
other.Artists = []Artist{{ID: "y", Name: "B"}}
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
|
||||
It("matches through errors.Join and wrapping", func() {
|
||||
err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{}))
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
It("false when a scalar differs", func() {
|
||||
other := base
|
||||
other.Name = "T"
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
|
||||
It("exposes the delay through the wrapped error", func() {
|
||||
err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second})
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(42 * time.Second))
|
||||
})
|
||||
It("true when both have empty Artists and equal scalars", func() {
|
||||
a := Song{ID: "1", Name: "S"}
|
||||
Expect(a.Equals(a)).To(BeTrue())
|
||||
|
||||
It("matches the sentinel too, reporting no delay", func() {
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(BeZero())
|
||||
})
|
||||
|
||||
It("is the same sentinel as scrobbler.ErrRetryLater", func() {
|
||||
Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue())
|
||||
Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const LocalAgentName = "local"
|
||||
|
|
@ -22,7 +24,7 @@ func (p *localAgent) AgentName() string {
|
|||
}
|
||||
|
||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Sort: "playCount",
|
||||
Order: "desc",
|
||||
Max: count,
|
||||
|
|
@ -37,14 +39,51 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []Song
|
||||
for _, s := range top {
|
||||
result = append(result, Song{
|
||||
Name: s.Title,
|
||||
MBID: s.MbzReleaseTrackID,
|
||||
})
|
||||
return songsFrom(top), nil
|
||||
}
|
||||
|
||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
seed, err := p.ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
|
||||
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
|
||||
if len(genreIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Max: count + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make(model.MediaFiles, 0, len(candidates))
|
||||
for _, s := range candidates {
|
||||
if s.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
if len(filtered) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return songsFrom(filtered), nil
|
||||
}
|
||||
|
||||
func songsFrom(mfs model.MediaFiles) []Song {
|
||||
if len(mfs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slice.Map(mfs, func(mf model.MediaFile) Song {
|
||||
return Song{ID: mf.ID, Name: mf.Title}
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
96
core/agents/local_agent_test.go
Normal file
96
core/agents/local_agent_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var agent *localAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
mfRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||
agent = &localAgent{ds: ds}
|
||||
})
|
||||
|
||||
It("excludes the seed track from its own similars", func() {
|
||||
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := slice.Map(songs, func(s Song) string { return s.Name })
|
||||
Expect(names).ToNot(ContainElement("Seed"))
|
||||
})
|
||||
|
||||
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
|
||||
// would pass just as well with no genre filter at all.
|
||||
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
|
||||
rock := model.NewTag(model.TagGenre, "Rock")
|
||||
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
|
||||
Expect(sqlErr).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
|
||||
Expect(sql).To(ContainSubstring("missing"))
|
||||
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
||||
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
|
||||
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
|
||||
})
|
||||
|
||||
It("returns the library id so the matcher can resolve the song", func() {
|
||||
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
|
||||
// so the local fallback silently returns an empty mix.
|
||||
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
|
||||
})
|
||||
|
||||
It("asks for one extra candidate so dropping the seed still fills the count", func() {
|
||||
// The mock returns rows sorted by id, so the seed comes first and would consume the only
|
||||
// slot if the query did not over-fetch.
|
||||
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].Name).To(Equal("Related"))
|
||||
})
|
||||
|
||||
It("returns nil when the seed track has no genres", func() {
|
||||
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
// Without the early return an empty tag filter would scan the whole library.
|
||||
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
|
||||
})
|
||||
})
|
||||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
|||
}
|
||||
|
||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
||||
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
||||
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
||||
}
|
||||
|
|
|
|||
27
core/agents/song_test.go
Normal file
27
core/agents/song_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Song.Equals", func() {
|
||||
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
|
||||
It("true for identical songs incl Artists", func() {
|
||||
Expect(base.Equals(base)).To(BeTrue())
|
||||
})
|
||||
It("false when Artists differ", func() {
|
||||
other := base
|
||||
other.Artists = []Artist{{ID: "y", Name: "B"}}
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
It("false when a scalar differs", func() {
|
||||
other := base
|
||||
other.Name = "T"
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
It("true when both have empty Artists and equal scalars", func() {
|
||||
a := Song{ID: "1", Name: "S"}
|
||||
Expect(a.Equals(a)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
177
core/archiver.go
177
core/archiver.go
|
|
@ -2,22 +2,32 @@ package core
|
|||
|
||||
import (
|
||||
"archive/zip"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const archiveCoverArtSize = 500
|
||||
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
|
|
@ -25,42 +35,50 @@ type Archiver interface {
|
|||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
shares Share
|
||||
ds model.DataStore
|
||||
ms stream.MediaStreamer
|
||||
shares Share
|
||||
artwork artwork.Artwork
|
||||
}
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
|
||||
// Match by album-artist participation, not the deprecated album_artist_id
|
||||
// column (first album artist only), so co-album-artists are included too.
|
||||
filter := squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
|
||||
}
|
||||
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
||||
// rootArt, when set, is added to the archive root.
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
|
||||
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
||||
return mf.AlbumID
|
||||
})
|
||||
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
||||
folders := albumFolders(albums)
|
||||
for _, album := range albums {
|
||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||
isMultiDisc := len(discs) > 1
|
||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
||||
folder := folders[album[0].AlbumID]
|
||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
file := a.albumFilename(mf, format, isMultiDisc, folder)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Stop iterating: continuing would just rack up more
|
||||
// rejections from the limiter. Close finalises whatever
|
||||
|
|
@ -71,7 +89,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
|||
return addErr
|
||||
}
|
||||
}
|
||||
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
|
||||
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
|
||||
}
|
||||
a.addCoverArtToZip(ctx, z, rootArt, "")
|
||||
err = z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
|
|
@ -89,7 +110,61 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
|||
return z
|
||||
}
|
||||
|
||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
||||
// Tried in order; the first one whose values are distinct across the clashing albums wins.
|
||||
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
|
||||
var albumDisambiguators = []func(model.MediaFile) string{
|
||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
|
||||
func(mf model.MediaFile) string {
|
||||
// Reissues share Year (often the original's) but not ReleaseYear.
|
||||
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
|
||||
return strconv.Itoa(y)
|
||||
}
|
||||
return ""
|
||||
},
|
||||
func(mf model.MediaFile) string { return mf.MbzAlbumType },
|
||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
|
||||
func(mf model.MediaFile) string { return mf.CatalogNum },
|
||||
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
|
||||
func(mf model.MediaFile) string { return mf.AlbumID },
|
||||
}
|
||||
|
||||
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
|
||||
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
|
||||
func albumFolders(albums [][]model.MediaFile) map[string]string {
|
||||
byName := map[string][]model.MediaFile{}
|
||||
for _, album := range albums {
|
||||
name := str.SanitizeFilename(album[0].FullAlbumName())
|
||||
byName[name] = append(byName[name], album[0])
|
||||
}
|
||||
folders := make(map[string]string, len(albums))
|
||||
for name, group := range byName {
|
||||
if len(group) == 1 {
|
||||
folders[group[0].AlbumID] = name
|
||||
continue
|
||||
}
|
||||
fields:
|
||||
for _, field := range albumDisambiguators {
|
||||
ids := make(map[string]string, len(group)) // suffix -> album id
|
||||
for _, mf := range group {
|
||||
s := str.SanitizeFilename(field(mf))
|
||||
if _, dup := ids[s]; dup {
|
||||
continue fields
|
||||
}
|
||||
ids[s] = mf.AlbumID
|
||||
}
|
||||
for s, id := range ids {
|
||||
folders[id] = name
|
||||
if s != "" {
|
||||
folders[id] = fmt.Sprintf("%s [%s]", name, s)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return folders
|
||||
}
|
||||
|
||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
if format != "raw" {
|
||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||
|
|
@ -97,7 +172,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
|||
if isMultiDisc {
|
||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||
return fmt.Sprintf("%s/%s", folder, file)
|
||||
}
|
||||
|
||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||
|
|
@ -107,21 +182,24 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
|||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
// The share is the authorization (as in the public image handler): an anonymous lookup would
|
||||
// hide a private playlist. Only the cover read is elevated.
|
||||
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
|
||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
|
|
@ -136,6 +214,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
|||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
|
|
@ -213,3 +292,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
|
||||
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
|
||||
if artID.ID == "" {
|
||||
return
|
||||
}
|
||||
// Buffered so a failed read leaves no empty entry.
|
||||
data, err := a.readCoverArt(ctx, artID)
|
||||
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
|
||||
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
|
||||
return
|
||||
}
|
||||
ext := coverArtExtension(data)
|
||||
if ext == "" {
|
||||
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
|
||||
return
|
||||
}
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: path.Join(dir, "folder."+ext),
|
||||
Modified: time.Now(),
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
|
||||
return
|
||||
}
|
||||
if _, err = w.Write(data); err != nil {
|
||||
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
|
||||
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer img.Close()
|
||||
return io.ReadAll(img)
|
||||
}
|
||||
|
||||
// Resizing may re-encode the image, so the type comes from its bytes.
|
||||
func coverArtExtension(data []byte) string {
|
||||
switch http.DetectContentType(data) {
|
||||
case "image/jpeg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,19 @@ import (
|
|||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
|
@ -22,13 +28,15 @@ var _ = Describe("Archiver", func() {
|
|||
ms *mockMediaStreamer
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
ca *mockCoverArt
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
sh = &mockShare{}
|
||||
ds = &mockDataStore{}
|
||||
arch = core.NewArchiver(ms, ds, sh)
|
||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||
arch = core.NewArchiver(ms, ds, sh, ca)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
|
|
@ -44,7 +52,7 @@ var _ = Describe("Archiver", func() {
|
|||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -69,11 +77,14 @@ var _ = Describe("Archiver", func() {
|
|||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_artist_id": "1"},
|
||||
Sort: "album",
|
||||
Filters: squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -87,6 +98,140 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||
})
|
||||
|
||||
When("albums that share a name", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
|
||||
zipArtistEntries := func(mfs model.MediaFiles) []string {
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
names := make([]string, len(zr.File))
|
||||
for i, f := range zr.File {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
It("keeps the albums in query order", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
|
||||
})
|
||||
|
||||
It("suffixes the year when it tells the albums apart", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
|
||||
})
|
||||
|
||||
It("prefers the release year, so reissues of the same original are told apart", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
|
||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("names the folder after the full album name", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
|
||||
})
|
||||
|
||||
It("prefers the album version over the year when it is not part of the name", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("leaves the one album without the field unsuffixed", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("skips a field that is empty on more than one album", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
|
||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("skips a field that is the same on every album", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("falls back to the album id when nothing differs", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("treats names that sanitize to the same folder as a clash", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("sanitizes the suffix", func() {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
|
||||
})
|
||||
|
||||
It("leaves the folder name alone when only one album has it", func() {
|
||||
names := zipArtistEntries(model.MediaFiles{
|
||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
|
||||
})
|
||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
|
|
@ -101,7 +246,7 @@ var _ = Describe("Archiver", func() {
|
|||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ds.On("MediaFile").Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
|
@ -165,7 +310,7 @@ var _ = Describe("Archiver", func() {
|
|||
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ds.On("Playlist").Return(plRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
|
|
@ -192,24 +337,195 @@ var _ = Describe("Archiver", func() {
|
|||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||
})
|
||||
})
|
||||
Context("cover art", func() {
|
||||
var (
|
||||
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
|
||||
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
|
||||
)
|
||||
|
||||
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
}
|
||||
|
||||
It("adds the album cover to the album folder", func() {
|
||||
ca.images["al-1"] = jpegData
|
||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(2))
|
||||
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
|
||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
|
||||
})
|
||||
|
||||
It("adds the artist image to the root and each album cover to its folder", func() {
|
||||
ca.images["ar-1"] = pngData
|
||||
ca.images["al-1"] = jpegData
|
||||
ca.images["al-2"] = jpegData
|
||||
mockAlbumTracks(squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(5))
|
||||
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
|
||||
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
|
||||
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
|
||||
})
|
||||
|
||||
It("puts each same-named album's cover in that album's own folder", func() {
|
||||
ca.images["al-1"] = jpegData
|
||||
ca.images["al-2"] = pngData
|
||||
mockAlbumTracks(squirrel.And{
|
||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||
squirrel.Eq{"missing": false},
|
||||
}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
|
||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
|
||||
})
|
||||
|
||||
It("adds the playlist cover to the root", func() {
|
||||
ca.images["pl-1"] = jpegData
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
|
||||
ID: "1",
|
||||
Name: "Test Playlist",
|
||||
Tracks: []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
|
||||
},
|
||||
}, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(3))
|
||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||
Expect(files).To(HaveKey("Test Playlist.m3u"))
|
||||
})
|
||||
|
||||
It("adds the shared item's cover to the root, even for a private playlist", func() {
|
||||
ca.images["pl-10"] = jpegData
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||
share := &model.Share{
|
||||
ID: "1",
|
||||
Downloadable: true,
|
||||
Format: "mp3",
|
||||
MaxBitRate: 128,
|
||||
ResourceType: "playlist",
|
||||
ResourceIDs: "10",
|
||||
Tracks: model.MediaFiles{
|
||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||
},
|
||||
}
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(2))
|
||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
|
||||
})
|
||||
|
||||
It("still builds the archive when the cover cannot be read", func() {
|
||||
ca.err = errors.New("boom")
|
||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
})
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||
|
||||
files := readZip(out)
|
||||
Expect(files).To(HaveLen(1))
|
||||
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func readZip(out *bytes.Buffer) map[string][]byte {
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := make(map[string][]byte, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_ = r.Close()
|
||||
files[f.Name] = data
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
type coverRequest struct {
|
||||
id string
|
||||
size int
|
||||
square bool
|
||||
admin bool
|
||||
}
|
||||
|
||||
type mockCoverArt struct {
|
||||
artwork.Artwork
|
||||
images map[string][]byte
|
||||
err error
|
||||
requests []coverRequest
|
||||
}
|
||||
|
||||
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
|
||||
user, _ := request.UserFrom(ctx)
|
||||
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
data, ok := m.images[artID.String()]
|
||||
if !ok {
|
||||
return nil, artwork.ErrUnavailable
|
||||
}
|
||||
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
|
||||
}
|
||||
|
||||
type mockDataStore struct {
|
||||
mock.Mock
|
||||
model.DataStore
|
||||
}
|
||||
|
||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
args := m.Called(ctx)
|
||||
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
||||
args := m.Called()
|
||||
return args.Get(0).(model.MediaFileRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
args := m.Called(ctx)
|
||||
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
||||
args := m.Called()
|
||||
return args.Get(0).(model.PlaylistRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
||||
func (m *mockDataStore) Library() model.LibraryRepository {
|
||||
return &mockLibraryRepository{}
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +534,7 @@ type mockLibraryRepository struct {
|
|||
model.LibraryRepository
|
||||
}
|
||||
|
||||
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
||||
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
||||
return "/music", nil
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +543,7 @@ type mockMediaFileRepository struct {
|
|||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
args := m.Called(options)
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
|
@ -237,7 +553,7 @@ type mockPlaylistRepository struct {
|
|||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package artwork
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
|
|
@ -41,17 +42,38 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL {
|
|||
return best
|
||||
}
|
||||
|
||||
// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no
|
||||
// longerRetry keeps whichever external failure asks for the longer wait, so one provider's
|
||||
// short delay cannot shorten another's.
|
||||
func longerRetry(a, b error) error {
|
||||
if a == nil {
|
||||
return b
|
||||
}
|
||||
var ra, rb *agents.RetryLaterError
|
||||
if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no
|
||||
// agent succeeded and at least one failed transiently.
|
||||
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) {
|
||||
func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) {
|
||||
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
|
||||
switch ar.ID {
|
||||
case consts.UnknownArtistID, consts.VariousArtistsID:
|
||||
return nil, "", false
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"})
|
||||
return nil, "", nil
|
||||
}
|
||||
name := externalName(ar.Name)
|
||||
for _, a := range ag.ArtistImageAgents() {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imageAgents := ag.ArtistImageAgents()
|
||||
if len(imageAgents) == 0 {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides artist images"})
|
||||
return nil, "", nil
|
||||
}
|
||||
var extErr error
|
||||
for _, a := range imageAgents {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
|
@ -62,11 +84,12 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
|||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
return reader, a.Name, nil
|
||||
}
|
||||
if isTransientExternal(err) {
|
||||
extErr = true
|
||||
extErr = longerRetry(extErr, err)
|
||||
log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,10 +97,17 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
|||
}
|
||||
|
||||
// fetchAlbumImage is the album counterpart of fetchArtistImage.
|
||||
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
|
||||
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) {
|
||||
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
|
||||
for _, a := range ag.AlbumImageAgents() {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imageAgents := ag.AlbumImageAgents()
|
||||
if len(imageAgents) == 0 {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides album images"})
|
||||
return nil, "", nil
|
||||
}
|
||||
var extErr error
|
||||
for _, a := range imageAgents {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
|
@ -88,11 +118,12 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
|
|||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
return reader, a.Name, nil
|
||||
}
|
||||
if isTransientExternal(err) {
|
||||
extErr = true
|
||||
extErr = longerRetry(extErr, err)
|
||||
log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package artwork
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
|
|
@ -153,11 +155,11 @@ var _ = Describe("agent images", func() {
|
|||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentA"))
|
||||
Expect(extErr).To(BeFalse())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips the external lookup for synthetic artists", func() {
|
||||
|
|
@ -165,14 +167,38 @@ var _ = Describe("agent images", func() {
|
|||
ag := imageAgents(a)
|
||||
|
||||
for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} {
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(name).To(BeEmpty())
|
||||
Expect(extErr).To(BeFalse())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
|
||||
})
|
||||
|
||||
It("records a skipped external candidate when no agent provides artist images", func() {
|
||||
ag := imageAgents()
|
||||
t := &ChainTrace{}
|
||||
|
||||
r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides artist images"}}),
|
||||
"a configured external token must never be silently absent from the chain")
|
||||
})
|
||||
|
||||
It("records a skipped external candidate for synthetic artists", func() {
|
||||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
t := &ChainTrace{}
|
||||
|
||||
_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
|
||||
model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
|
||||
Expect(t.Steps()).To(HaveLen(1))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
|
||||
Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
|
||||
})
|
||||
|
||||
It("clears typographic characters from the query name unless preserving unicode", func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
a := &fakeImageAgent{name: "agentA"}
|
||||
|
|
@ -187,11 +213,11 @@ var _ = Describe("agent images", func() {
|
|||
b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentB"))
|
||||
Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error")
|
||||
Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error")
|
||||
Expect(a.artistCalls).To(Equal(1))
|
||||
Expect(b.artistCalls).To(Equal(1))
|
||||
})
|
||||
|
|
@ -201,20 +227,43 @@ var _ = Describe("agent images", func() {
|
|||
b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(name).To(BeEmpty())
|
||||
Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure")
|
||||
Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure")
|
||||
})
|
||||
|
||||
It("reports extErr when one agent fails transiently and the rest find nothing", func() {
|
||||
It("reports an error when one agent fails transiently and the rest find nothing", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound}
|
||||
b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(extErr).To(BeTrue())
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
// The worker reschedules on this delay, so it is only honored if the agent loop
|
||||
// returns it. Two throttled agents: the longest wait is the one that must survive.
|
||||
It("returns the longest retry delay the providers asked for", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}}
|
||||
b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}}
|
||||
ag := imageAgents(a, b)
|
||||
|
||||
r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(r).To(BeNil())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(10 * time.Second))
|
||||
})
|
||||
|
||||
It("returns no delay when the provider did not ask for one", func() {
|
||||
ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")})
|
||||
|
||||
_, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
_, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -223,21 +272,33 @@ var _ = Describe("agent images", func() {
|
|||
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
|
||||
r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
Expect(name).To(Equal("agentA"))
|
||||
Expect(extErr).To(BeFalse())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(a.albumCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("reports extErr when the only agent fails transiently", func() {
|
||||
It("records a skipped external candidate when no agent provides album images", func() {
|
||||
ag := imageAgents()
|
||||
t := &ChainTrace{}
|
||||
|
||||
r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
|
||||
Detail: "no enabled agent provides album images"}}),
|
||||
"a configured external token must never be silently absent from the chain")
|
||||
})
|
||||
|
||||
It("reports an error when the only agent fails transiently", func() {
|
||||
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
|
||||
ag := imageAgents(a)
|
||||
|
||||
r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
|
||||
r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
|
||||
Expect(r).To(BeNil())
|
||||
Expect(extErr).To(BeTrue())
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -58,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
|||
var err error
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||
found, err = ds.Artist().Exists(ctx, artID.ID)
|
||||
case model.KindAlbumArtwork:
|
||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||
found, err = ds.Album().Exists(ctx, artID.ID)
|
||||
case model.KindMediaFileArtwork:
|
||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
||||
case model.KindPlaylistArtwork:
|
||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
||||
case model.KindRadioArtwork:
|
||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||
found, err = ds.Radio().Exists(ctx, artID.ID)
|
||||
case model.KindDiscArtwork:
|
||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||
if perr != nil {
|
||||
return false
|
||||
}
|
||||
found, err = ds.Album(ctx).Exists(albumID)
|
||||
found, err = ds.Album().Exists(ctx, albumID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -117,22 +118,15 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
|||
}
|
||||
}
|
||||
|
||||
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
|
||||
// hammer external services; below staleAbsentAge to catch younger absences.
|
||||
const requestRecheckAge = time.Hour
|
||||
|
||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return s.provisional(ctx, artID, size, square)
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case ia.Hash == "":
|
||||
// Inserts an immediately-eligible recheck for a settled absent row.
|
||||
if time.Since(ia.AttemptedAt) > requestRecheckAge {
|
||||
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
|
||||
}
|
||||
// Settled absent: only an explicit reprocess or refresh retries it.
|
||||
return nil, ErrUnavailable
|
||||
default:
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
|
|
@ -174,7 +168,12 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
|||
if !entityExists(ctx, s.ds, artID) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
|
||||
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
|
||||
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
||||
return s.dangling(ctx, artID)
|
||||
}
|
||||
art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return s.dangling(ctx, artID)
|
||||
|
|
@ -265,13 +264,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
|||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||
if !conf.Server.EnableMediaFileCoverArt {
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||
}
|
||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||
switch {
|
||||
case err == nil && ia.Hash != "":
|
||||
return s.serveHash(ctx, artID, ia, size, square)
|
||||
|
|
@ -284,7 +283,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
|||
}
|
||||
noRow := errors.Is(err, model.ErrNotFound)
|
||||
|
||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -317,16 +316,15 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
|
|||
return nil, err
|
||||
}
|
||||
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
||||
selectImage := func() (io.ReadCloser, string, error) {
|
||||
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
|
||||
return selectImageReader(ctx, artID, funcs...)
|
||||
selectImage := func() (io.ReadCloser, error) {
|
||||
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
|
||||
return res.reader, err
|
||||
}
|
||||
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
||||
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
||||
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
||||
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square,
|
||||
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
|
||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
|
|
@ -344,7 +342,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
|
|||
}
|
||||
|
||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: artID.Kind.Prefix(),
|
||||
ItemID: artID.ID,
|
||||
ImageType: model.ImageTypePrimary,
|
||||
|
|
@ -386,6 +384,53 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI
|
|||
return model.ArtworkID{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
|
||||
// the walk and reports the winning source, without ever writing artwork state.
|
||||
type TracingResolver struct {
|
||||
inner *resolver
|
||||
trace *ChainTrace
|
||||
}
|
||||
|
||||
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
|
||||
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
|
||||
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
|
||||
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
|
||||
inner := newLocalResolver(ds, ffm)
|
||||
if live {
|
||||
inner = newResolver(ds, ag, ffm, passthroughGate)
|
||||
}
|
||||
return &TracingResolver{inner: inner, trace: t}
|
||||
}
|
||||
|
||||
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
|
||||
// ("" when none produced an image).
|
||||
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
|
||||
switch kind {
|
||||
case model.KindArtistArtwork:
|
||||
return r.explain(ctx, r.inner.resolveArtist, id)
|
||||
case model.KindAlbumArtwork:
|
||||
return r.explain(ctx, r.inner.resolveAlbum, id)
|
||||
case model.KindDiscArtwork:
|
||||
return r.explain(ctx, r.inner.resolveDisc, id)
|
||||
case model.KindMediaFileArtwork:
|
||||
return r.explain(ctx, r.inner.resolveMediaFile, id)
|
||||
}
|
||||
return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
|
||||
}
|
||||
|
||||
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
|
||||
// would close the reader either.
|
||||
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
|
||||
res, err := resolve(withTrace(ctx, r.trace), id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.reader != nil {
|
||||
_ = res.reader.Close()
|
||||
}
|
||||
return res.source, nil
|
||||
}
|
||||
|
||||
func unixMtime(mtime int64) time.Time {
|
||||
if mtime <= 0 {
|
||||
return time.Time{}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
|
|
@ -37,6 +41,14 @@ func TestArtwork(t *testing.T) {
|
|||
RunSpecs(t, "Artwork Suite")
|
||||
}
|
||||
|
||||
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
|
||||
var productionImageClient = remoteImageClient
|
||||
|
||||
// httptest servers listen on loopback, which the production client refuses.
|
||||
var _ = BeforeSuite(func() {
|
||||
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
|
||||
})
|
||||
|
||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||
type osDirFS struct{ fs.FS }
|
||||
|
||||
|
|
@ -97,15 +109,15 @@ type fakeFolderRepo struct {
|
|||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
|||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||
seedEntity(kind, id)
|
||||
return hash
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
|||
GinkgoHelper()
|
||||
switch kind {
|
||||
case "al":
|
||||
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||
case "mf":
|
||||
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
||||
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
|
|||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
mtime := fileMtime(imgPath)
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al2")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||
})).To(Succeed())
|
||||
|
|
@ -159,13 +159,60 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
})
|
||||
|
||||
It("treats a file-backed row pointing at a non-image file as dangling", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "alni")
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
})
|
||||
|
||||
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
|
||||
secret := []byte("password=secret")
|
||||
dir := GinkgoT().TempDir()
|
||||
secretPath := filepath.Join(dir, "config.ini")
|
||||
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "alnic")
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||
})).To(Succeed())
|
||||
|
||||
// Older versions cached the raw bytes when the resize failed.
|
||||
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
|
||||
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(io.ReadAll(stream)).To(Equal(secret))
|
||||
Expect(stream.Close()).To(Succeed())
|
||||
Eventually(func(g Gomega) {
|
||||
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
|
||||
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(s.Cached).To(BeTrue())
|
||||
_ = s.Close()
|
||||
}).Should(Succeed())
|
||||
|
||||
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
})
|
||||
|
||||
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
|
@ -173,7 +220,7 @@ var _ = Describe("Artwork", func() {
|
|||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||
})
|
||||
|
|
@ -182,9 +229,9 @@ var _ = Describe("Artwork", func() {
|
|||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||
seedEntity("al", "al3b")
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||
})).To(Succeed())
|
||||
|
|
@ -204,28 +251,15 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
|
||||
It("does not re-enqueue a recently-attempted absent state", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(),
|
||||
It("never re-enqueues an absent state on view, however old", func() {
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("promotes a stale absent state at Bump priority on view", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge),
|
||||
})).To(Succeed())
|
||||
|
||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("provisional read-through", func() {
|
||||
|
|
@ -238,7 +272,7 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(readAll(img)).To(Equal(coverBytes))
|
||||
|
||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -249,7 +283,7 @@ var _ = Describe("Artwork", func() {
|
|||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
|
@ -276,7 +310,7 @@ var _ = Describe("Artwork", func() {
|
|||
|
||||
It("delegates to the album when the track's state is absent", func() {
|
||||
seedFoundStore("al", "albm", coverBytes)
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||
|
|
@ -309,7 +343,7 @@ var _ = Describe("Artwork", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
|
|
@ -460,7 +494,7 @@ var _ = Describe("Artwork", func() {
|
|||
})
|
||||
|
||||
It("falls back to the artist placeholder for an absent artist", func() {
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||
|
||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -501,7 +535,7 @@ var _ = Describe("EntityExists", func() {
|
|||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ func Encode(img image.Image) (string, error) {
|
|||
|
||||
lin := srgbToLinearTable()
|
||||
factors := make([][3]float64, xComp*yComp)
|
||||
linR := make([]float64, w)
|
||||
linG := make([]float64, w)
|
||||
linB := make([]float64, w)
|
||||
rowR := make([]float64, xComp)
|
||||
rowG := make([]float64, xComp)
|
||||
rowB := make([]float64, xComp)
|
||||
for y := range h {
|
||||
row := src.pix[y*src.stride:]
|
||||
for x := range w {
|
||||
|
|
@ -60,15 +66,26 @@ func Encode(img image.Image) (string, error) {
|
|||
if src.straight {
|
||||
r, g, b = premultiply(r, g, b, row[p+3])
|
||||
}
|
||||
lr, lg, lb := lin[r], lin[g], lin[b]
|
||||
for j := range yComp {
|
||||
for i := range xComp {
|
||||
basis := cosX[i][x] * cosY[j][y]
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += basis * lr
|
||||
f[1] += basis * lg
|
||||
f[2] += basis * lb
|
||||
}
|
||||
linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b]
|
||||
}
|
||||
// The basis is separable, so a row costs xComp dot products plus one fold over yComp,
|
||||
// rather than xComp*yComp multiply-accumulates per pixel.
|
||||
for i := range xComp {
|
||||
var sr, sg, sb float64
|
||||
for x, c := range cosX[i] {
|
||||
sr += c * linR[x]
|
||||
sg += c * linG[x]
|
||||
sb += c * linB[x]
|
||||
}
|
||||
rowR[i], rowG[i], rowB[i] = sr, sg, sb
|
||||
}
|
||||
for j := range yComp {
|
||||
cy := cosY[j][y]
|
||||
for i := range xComp {
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += cy * rowR[i]
|
||||
f[1] += cy * rowG[i]
|
||||
f[2] += cy * rowB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||
}
|
||||
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
al, err := ds.Album().Get(ctx, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
}
|
||||
|
||||
// Query mediafiles for this album + disc to find folder associations and first track
|
||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Sort: "track_number",
|
||||
Order: "ASC",
|
||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||
|
|
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
// Resolve folder IDs to library-relative paths
|
||||
discFoldersRel := make(map[string]bool)
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -113,27 +113,71 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at
|
||||
// all, so a chain walk can say why instead of leaving a configured entry unaccounted for.
|
||||
type discCandidate struct {
|
||||
pattern string
|
||||
resolve func() (resolution, bool)
|
||||
skip string
|
||||
}
|
||||
|
||||
func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate {
|
||||
folder := func(sf sourceFunc) func() (resolution, bool) {
|
||||
return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) }
|
||||
}
|
||||
var cc []discCandidate
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
c := discCandidate{pattern: pattern}
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
ff = append(ff,
|
||||
fromTag(ctx, d.lib.FS, d.firstTrackRel),
|
||||
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
|
||||
)
|
||||
case pattern == "external":
|
||||
// Not supported for disc art, silently ignore
|
||||
case pattern == "discsubtitle":
|
||||
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
|
||||
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
|
||||
c.resolve = func() (resolution, bool) {
|
||||
return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel)
|
||||
}
|
||||
case len(d.imgFiles) > 0:
|
||||
ff = append(ff, d.fromExternalFile(ctx, pattern))
|
||||
case pattern == externalCandidate:
|
||||
c.skip = "external sources are not supported for disc artwork"
|
||||
case pattern == "discsubtitle":
|
||||
subtitle := strings.TrimSpace(d.album.Discs[d.discNumber])
|
||||
if subtitle == "" {
|
||||
c.skip = "disc has no subtitle"
|
||||
} else {
|
||||
c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle))
|
||||
}
|
||||
case len(d.imgFiles) == 0:
|
||||
c.skip = "no images in album folder"
|
||||
default:
|
||||
c.resolve = folder(d.fromExternalFile(ctx, pattern))
|
||||
}
|
||||
cc = append(cc, c)
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// selectImage walks the DiscArtPriority entries and returns the first that yields an image.
|
||||
// chain records the walk; the serving path passes an untraced one and pays nothing for it.
|
||||
func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string,
|
||||
chain *chainState) (resolution, error) {
|
||||
for _, c := range d.discCandidates(ctx, ffmpeg, priority) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if c.skip != "" {
|
||||
chain.record(c.pattern, OutcomeSkipped, c.skip)
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
res, ok := c.resolve()
|
||||
log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID,
|
||||
"disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath,
|
||||
"elapsed", time.Since(start))
|
||||
if res, ok = chain.try(c.pattern, res, ok); ok {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return ff
|
||||
return chain.exhausted(), nil
|
||||
}
|
||||
|
||||
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -179,19 +181,19 @@ var _ = Describe("Disc Artwork Reader", func() {
|
|||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
|
||||
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err := ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f2))
|
||||
r.Close()
|
||||
cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*")
|
||||
Expect(cc).To(HaveLen(2))
|
||||
res, ok := cc[0].resolve()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2)))
|
||||
res.reader.Close()
|
||||
|
||||
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
|
||||
Expect(ff).To(HaveLen(2))
|
||||
r, path, err = ff[0]()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(f1))
|
||||
r.Close()
|
||||
cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*")
|
||||
Expect(cc).To(HaveLen(2))
|
||||
res, ok = cc[0].resolve()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1)))
|
||||
res.reader.Close()
|
||||
})
|
||||
|
||||
DescribeTable("numbered match wins over shared fallback within a pattern",
|
||||
|
|
@ -428,64 +430,109 @@ var _ = Describe("Disc Artwork Reader", func() {
|
|||
})
|
||||
|
||||
Describe("discArtworkReader", func() {
|
||||
Describe("fromDiscArtPriority", func() {
|
||||
var (
|
||||
reader *discArtworkReader
|
||||
tmpDir string
|
||||
var (
|
||||
reader *discArtworkReader
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFoldersRel: map[string]bool{"music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"music/album/cd1/disc.jpg",
|
||||
"music/album/cd2/disc.jpg",
|
||||
"music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackRel: "music/album/cd2/track1.flac",
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("selectImage", func() {
|
||||
It("abandons the walk when the context is cancelled", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{})
|
||||
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(res.reader).To(BeNil())
|
||||
})
|
||||
|
||||
// "the track has no embedded art" and "the track is there but unreadable" are the two
|
||||
// answers a wrong-artwork report needs told apart; only the second is worth retrying.
|
||||
It("reports a track it cannot parse as unreadable, not as a miss", func() {
|
||||
trace := &ChainTrace{}
|
||||
track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel))
|
||||
Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed())
|
||||
|
||||
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
|
||||
&chainState{trace: trace})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.localError).To(BeTrue())
|
||||
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}}))
|
||||
})
|
||||
|
||||
It("reports a disc with no tracks to read as a miss", func() {
|
||||
trace := &ChainTrace{}
|
||||
reader.firstTrackRel = ""
|
||||
|
||||
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
|
||||
&chainState{trace: trace})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read")
|
||||
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("discCandidates", func() {
|
||||
It("returns a resolvable candidate for glob patterns", func() {
|
||||
cc := reader.discCandidates(context.Background(), nil, "disc*.*")
|
||||
Expect(cc).To(HaveLen(1))
|
||||
Expect(cc[0].resolve).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns one candidate per entry, in order", func() {
|
||||
cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded")
|
||||
Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })).
|
||||
To(Equal([]string{"disc*.*", "cd*.*", "embedded"}))
|
||||
})
|
||||
|
||||
It("skips an empty entry rather than building a glob that matches nothing", func() {
|
||||
cc := reader.discCandidates(context.Background(), nil, "disc*.*,")
|
||||
Expect(cc).To(HaveLen(1))
|
||||
})
|
||||
|
||||
// The skip reasons below are what `artwork explain` prints, so an entry that maps to no
|
||||
// source must say why instead of vanishing from the walk.
|
||||
DescribeTable("keeps an entry that maps to no source, with its reason",
|
||||
func(setup func(), priority, reason string) {
|
||||
setup()
|
||||
cc := reader.discCandidates(context.Background(), nil, priority)
|
||||
Expect(cc).To(HaveLen(1))
|
||||
Expect(cc[0].resolve).To(BeNil())
|
||||
Expect(cc[0].skip).To(Equal(reason))
|
||||
},
|
||||
Entry("external is unsupported", func() {}, "external",
|
||||
"external sources are not supported for disc artwork"),
|
||||
Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*",
|
||||
"no images in album folder"),
|
||||
Entry("the disc has no subtitle",
|
||||
func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle",
|
||||
"disc has no subtitle"),
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
reader = &discArtworkReader{
|
||||
discNumber: 2,
|
||||
isMultiFolder: true,
|
||||
discFoldersRel: map[string]bool{"music/album/cd2": true},
|
||||
imgFiles: []string{
|
||||
"music/album/cd1/disc.jpg",
|
||||
"music/album/cd2/disc.jpg",
|
||||
"music/album/cd2/disc2.jpg",
|
||||
},
|
||||
firstTrackRel: "music/album/cd2/track1.flac",
|
||||
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns source funcs for glob patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns source funcs for embedded pattern", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
|
||||
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("handles multiple comma-separated patterns", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
|
||||
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
|
||||
})
|
||||
|
||||
It("ignores 'external' pattern silently", func() {
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
|
||||
reader.imgFiles = nil
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns source func for discsubtitle pattern", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns no source func for discsubtitle when disc has no subtitle", func() {
|
||||
reader.album = model.Album{Discs: model.Discs{2: ""}}
|
||||
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
|
||||
Expect(ff).To(HaveLen(0))
|
||||
cc := reader.discCandidates(context.Background(), nil, "discsubtitle")
|
||||
Expect(cc).To(HaveLen(1))
|
||||
Expect(cc[0].resolve).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -43,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
|
||||
itemFound := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash != ""
|
||||
}
|
||||
}
|
||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||
return func() bool {
|
||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||
return err == nil && ia.Hash == ""
|
||||
}
|
||||
}
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
bump := func(kind, id string) {
|
||||
GinkgoHelper()
|
||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
|
|
@ -105,7 +106,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, errors.New("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue())
|
||||
Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue())
|
||||
|
||||
svc = artwork.NewArtwork(ds, imgCache, store, ffm)
|
||||
worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
|
|
@ -116,12 +117,31 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
}
|
||||
|
||||
It("acquires and serves a cover whose format has no registered decoder (#5950)", func() {
|
||||
libDir := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
|
||||
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
|
||||
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Placeholder).To(BeFalse())
|
||||
Expect(readAll(img)).To(Equal(jxlFixture))
|
||||
})
|
||||
|
||||
It("acquires album folder art and serves the exact bytes under its hash", func() {
|
||||
seedFolderAlbum("al1")
|
||||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
|
|
@ -138,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ar", "ar1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
|
|
@ -155,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("pl", "pl1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Hash).To(Equal(ia.Hash))
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/png"))
|
||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||
|
|
@ -174,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
||||
|
|
@ -196,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
provisionalBytes := readAll(provisional)
|
||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||
|
||||
// The provisional read enqueued a Bump; drain it.
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||
|
|
@ -217,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("al", "al1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||
Expect(art.Width).To(BeNumerically(">", 0))
|
||||
|
|
@ -239,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.Mime).To(Equal("image/gif"))
|
||||
Expect(art.Width).To(BeNumerically("==", 4))
|
||||
|
|
@ -259,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||
})
|
||||
|
||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||
|
|
@ -273,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
bump("ra", "ra1")
|
||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
staleHash := ia.Hash
|
||||
|
||||
|
|
@ -288,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
|||
|
||||
// That failed read enqueued a re-resolution.
|
||||
runWorkerUntil(ctx, worker, func() bool {
|
||||
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||
})
|
||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||
|
|
@ -317,6 +337,9 @@ func mustGet(img *artwork.Image, err error) *artwork.Image {
|
|||
}
|
||||
|
||||
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
|
||||
// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
|
||||
var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
|
||||
|
||||
// the production import the spec above guards.
|
||||
var gifFixture = []byte{
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,28 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
})
|
||||
})
|
||||
|
||||
When("the artist's only album folder has no images of its own", func() {
|
||||
// Artist/
|
||||
// ├── backdrop1.jpg
|
||||
// ├── folder.jpg ← matched by folder.*
|
||||
// ├── logo.png
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── 02 - Track.mp3
|
||||
It("resolves the artist folder, not the library root", func() {
|
||||
conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||
"Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||
"Artist/backdrop1.jpg": smallPNG("backdrop"),
|
||||
"Artist/folder.jpg": smallPNG("artist-folder"),
|
||||
"Artist/logo.png": smallPNG("logo"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("the artist's only album has its tracks in disc subfolders", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||
|
|
@ -179,7 +201,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
uploaded := ar.ID + "_upload.jpg"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||
ar.UploadedImage = uploaded
|
||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
|
@ -257,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
|||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
|
|||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
// Long enough for one retry (3-7s backoff, 5s poll tick).
|
||||
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
|
@ -66,13 +67,17 @@ type fakeFolderRepo struct {
|
|||
result []model.Folder
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, nil
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func writeUpload(entityType, name, srcFixture string) string {
|
||||
GinkgoHelper()
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
|||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
})
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
|||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(4))
|
||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||
|
||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID(ids)
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
|||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
|||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
|||
// (no files on disk — the resolver has no sources to fall back to)
|
||||
It("settles absent", func() {
|
||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
|
|
|
|||
|
|
@ -99,11 +99,11 @@ func setupResolutionHarness() {
|
|||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
|
||||
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadEmbeddedFixture()
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ func setupResolutionHarness() {
|
|||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, fmt.Errorf("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue())
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue())
|
||||
|
||||
rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm)
|
||||
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
|
|
@ -140,13 +140,13 @@ func scan() {
|
|||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||
GinkgoHelper()
|
||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
|
||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
})).To(Succeed())
|
||||
var ia *model.ItemArtwork
|
||||
runResolutionWorkerUntil(func() bool {
|
||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
|
|||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||
func requireNoStateRow(kind model.Kind, id string) {
|
||||
GinkgoHelper()
|
||||
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
_, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||
}
|
||||
|
|
@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
|
|||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||
func storedBytes(ia model.ItemArtwork) []byte {
|
||||
GinkgoHelper()
|
||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
|
|||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
|
|
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
|
|||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue