mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-12 05:30:39 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec03d8780 | ||
|
|
5b01636d33 | ||
|
|
fda536ff17 | ||
|
|
91647ed46f |
No files matched your search
@@ -73,7 +73,7 @@ runs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
github-token: ${{ inputs.github_token }}
|
||||
token: ${{ inputs.github_token }}
|
||||
labels: |
|
||||
maintainer=deluan@navidrome.org
|
||||
images: |
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
# This snippet is public-domain, taken from
|
||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
||||
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Show git version info
|
||||
run: |
|
||||
echo "git describe (dirty): $(git describe --dirty --always --tags)"
|
||||
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
|
||||
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
|
||||
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
|
||||
echo "github_ref: $GITHUB_REF"
|
||||
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Determine git current SHA and latest tag
|
||||
id: git-version
|
||||
run: |
|
||||
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
||||
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
|
||||
if [ -n "$GIT_TAG" ]; then
|
||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||
GIT_TAG=${GIT_TAG}-SNAPSHOT
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -96,33 +96,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate-migrations:
|
||||
name: Validate DB migrations
|
||||
runs-on: ubuntu-latest
|
||||
# PR-only gate is at step level: a job-level skip would propagate through
|
||||
# the needs chain (actions/runner#491) and skip all release jobs on tag pushes.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Refresh the base branch so the check compares against its CURRENT tip,
|
||||
# not the (possibly stale) commit the PR was opened against.
|
||||
- name: Fetch latest base branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}"
|
||||
- name: Validate migration ordering and naming
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
|
||||
run: ./.github/workflows/validate-migrations.sh
|
||||
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -148,7 +127,7 @@ jobs:
|
||||
FFMPEG_VERSION: "7.1"
|
||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -166,7 +145,7 @@ jobs:
|
||||
|
||||
- name: Cache ffmpeg
|
||||
id: ffmpeg-cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: C:\ffmpeg
|
||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
||||
@@ -220,7 +199,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -251,7 +230,7 @@ jobs:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
@@ -278,7 +257,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-windows, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
@@ -297,7 +276,7 @@ jobs:
|
||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare Docker Buildx
|
||||
uses: ./.github/actions/prepare-docker
|
||||
@@ -321,21 +300,6 @@ jobs:
|
||||
GIT_SHA=${{ env.GIT_SHA }}
|
||||
GIT_TAG=${{ env.GIT_TAG }}
|
||||
|
||||
- name: Set up QEMU for smoke test
|
||||
if: env.IS_LINUX == 'true'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
# The binary is static, so binfmt+qemu runs it directly on the runner.
|
||||
# Catches startup crashes in cross-compiled binaries before they ship,
|
||||
# e.g. the broken ifunc relocations on 32-bit arm from issue #5738.
|
||||
- name: Smoke-test binary
|
||||
if: env.IS_LINUX == 'true'
|
||||
run: |
|
||||
BIN=./output/${{ env.PLATFORM }}/navidrome
|
||||
chmod +x "$BIN"
|
||||
"$BIN" --help >/dev/null
|
||||
echo "OK: ${{ matrix.platform }} binary starts"
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -386,7 +350,7 @@ jobs:
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -420,7 +384,7 @@ jobs:
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -473,7 +437,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -507,7 +471,7 @@ jobs:
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -527,7 +491,7 @@ jobs:
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: '2.16.0'
|
||||
version: '~> v2'
|
||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get updated translations
|
||||
id: poeditor
|
||||
env:
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Validates DB migrations added by a pull request:
|
||||
# 1. Ordering - an added migration must be NEWER than the latest migration
|
||||
# already on the base branch. Goose applies migrations in
|
||||
# timestamp order, so an older-timestamped migration would be
|
||||
# silently skipped on databases already upgraded past it.
|
||||
# 2. Uniqueness - no two migration files may share a timestamp.
|
||||
# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go).
|
||||
#
|
||||
# On failure it prints a human-readable message and, when running in GitHub
|
||||
# Actions, emits an error annotation bound to the offending file so the message
|
||||
# also renders inline in the PR "Files changed" tab.
|
||||
#
|
||||
# Compares HEAD against $BASE_REF (default origin/master). Requires full history
|
||||
# (fetch-depth: 0 in CI).
|
||||
# -e is intentionally omitted: the script accumulates violations into $status
|
||||
# and must not exit on the first non-zero command (grep no-match, a false [[ ]]
|
||||
# in an if, `is_migration || continue`).
|
||||
set -uo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
MIGRATIONS_DIR="db/migrations"
|
||||
BASE_REF="${BASE_REF:-origin/master}"
|
||||
NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$'
|
||||
|
||||
status=0
|
||||
|
||||
# Log a message to stderr and mark the run as failed.
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
status=1
|
||||
}
|
||||
|
||||
# Emit a GitHub Actions error annotation bound to a file, so the message renders
|
||||
# inline on the offending migration in the PR "Files changed" tab. No-op outside
|
||||
# CI. `%`, newline and CR are encoded as required by the workflow-command syntax
|
||||
# (the `%` replacement must run first so the encodings we add aren't re-escaped).
|
||||
annotate() { # $1=file $2=message
|
||||
[ "${GITHUB_ACTIONS:-}" = "true" ] || return 0
|
||||
local msg="$2"
|
||||
msg="${msg//'%'/%25}"
|
||||
msg="${msg//$'\n'/%0A}"
|
||||
msg="${msg//$'\r'/%0D}"
|
||||
printf '::error file=%s,line=1::%s\n' "$1" "$msg"
|
||||
}
|
||||
|
||||
# Report a migration problem: log it, annotate the offending file, mark failed.
|
||||
report() { # $1=file $2=message
|
||||
fail "$2"
|
||||
printf '\n' >&2
|
||||
annotate "$1" "$2"
|
||||
}
|
||||
|
||||
human_ts() {
|
||||
local t="$1"
|
||||
printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}"
|
||||
}
|
||||
|
||||
is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix
|
||||
local b="$1"
|
||||
case "$b" in
|
||||
*.sql | *.go) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
[[ "${b%%_*}" =~ ^[0-9]{14}$ ]]
|
||||
}
|
||||
|
||||
if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then
|
||||
printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Newest timestamp already on the base branch ---
|
||||
base_max=""
|
||||
base_max_file=""
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
b="$(basename "$f")"
|
||||
is_migration "$b" || continue
|
||||
ts="${b%%_*}"
|
||||
if [[ "$ts" > "$base_max" ]]; then
|
||||
base_max="$ts"
|
||||
base_max_file="$f"
|
||||
fi
|
||||
done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null)
|
||||
|
||||
# --- Ordering + naming on files added by this PR ---
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
b="$(basename "$f")"
|
||||
case "$b" in
|
||||
*.sql) ;; # any .sql in this dir must be a migration
|
||||
*.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then
|
||||
report "$f" "❌ Migration file in a subdirectory: $f
|
||||
Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and
|
||||
top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED.
|
||||
Move it to $MIGRATIONS_DIR/$b."
|
||||
continue
|
||||
fi
|
||||
if ! [[ "$b" =~ $NAME_RE ]]; then
|
||||
report "$f" "❌ Malformed migration filename: $f
|
||||
Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase.
|
||||
Regenerate with: make migration-sql name=<description> (or make migration-go name=<description>)"
|
||||
continue
|
||||
fi
|
||||
ts="${b%%_*}"
|
||||
if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then
|
||||
report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts"))
|
||||
is older than (or equal to) the newest migration already on ${BASE_REF#origin/}:
|
||||
$base_max_file ($(human_ts "$base_max"))
|
||||
|
||||
Goose applies migrations in timestamp order, so databases already upgraded
|
||||
past that point would SILENTLY SKIP your migration.
|
||||
|
||||
Fix: regenerate it with a current timestamp:
|
||||
make migration-sql name=<description> (or make migration-go name=<description>)
|
||||
then move your SQL/Go body into the new file and delete the old one."
|
||||
fi
|
||||
done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)
|
||||
|
||||
# --- Duplicate timestamps across the merged set (HEAD) ---
|
||||
all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)"
|
||||
dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do
|
||||
b="$(basename "$f")"
|
||||
is_migration "$b" || continue
|
||||
printf '%s\n' "${b%%_*}"
|
||||
done | sort | uniq -d)"
|
||||
if [ -n "$dups" ]; then
|
||||
while IFS= read -r ts; do
|
||||
[ -z "$ts" ] && continue
|
||||
colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)"
|
||||
printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2
|
||||
while IFS= read -r cf; do
|
||||
[ -z "$cf" ] && continue
|
||||
printf ' %s\n' "$cf" >&2
|
||||
annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*."
|
||||
done <<< "$colliding"
|
||||
printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2
|
||||
status=1
|
||||
done <<< "$dups"
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "✅ DB migrations OK (ordering, uniqueness, naming)."
|
||||
fi
|
||||
exit "$status"
|
||||
+1
-2
@@ -37,6 +37,5 @@ AGENTS.md
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
.agents
|
||||
go.work*
|
||||
.worktrees/
|
||||
.worktrees/
|
||||
@@ -13,7 +13,6 @@ linters:
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errorlint
|
||||
- forbidigo
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
@@ -37,14 +36,6 @@ linters:
|
||||
- G401
|
||||
- G505
|
||||
- G115
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: 'tx\.Exec$'
|
||||
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
|
||||
- pattern: 'tx\.Query$'
|
||||
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
|
||||
- pattern: 'tx\.QueryRow$'
|
||||
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
@@ -54,9 +45,6 @@ linters:
|
||||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
- path-except: 'db/migrations/'
|
||||
linters:
|
||||
- forbidigo
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
@@ -68,7 +56,6 @@ linters:
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
- _gen\.go$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
||||
+4
-18
@@ -69,15 +69,12 @@ RUN --mount=type=bind,source=. \
|
||||
set -e
|
||||
xx-go --wrap
|
||||
export CGO_ENABLED=1
|
||||
BUILD_TAGS=$(./release/build-tags.sh)
|
||||
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
|
||||
go build -tags="${BUILD_TAGS}" -ldflags="-w -s \
|
||||
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
|
||||
-linkmode=external -extldflags '-latomic' \
|
||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||
-o /out/navidrome .
|
||||
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
|
||||
./release/verify-binary.sh /out/navidrome
|
||||
# Fail the build if the binary is accidentally statically linked: dlopen (and
|
||||
# therefore native libwebp detection) only works with a dynamic interpreter.
|
||||
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
|
||||
@@ -111,12 +108,11 @@ RUN --mount=type=bind,source=. \
|
||||
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
||||
--mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/go/pkg/mod <<EOT
|
||||
set -e
|
||||
|
||||
# Setup CGO cross-compilation environment
|
||||
xx-go --wrap
|
||||
export CGO_ENABLED=1
|
||||
cat "$(go env GOENV)" 2>/dev/null || true
|
||||
cat $(go env GOENV)
|
||||
|
||||
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
||||
# So let's use gcc for everything except Darwin.
|
||||
@@ -125,25 +121,14 @@ RUN --mount=type=bind,source=. \
|
||||
export CXX=$(xx-info)-g++
|
||||
export LD_EXTRA="-extldflags '-static -latomic'"
|
||||
fi
|
||||
# GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers
|
||||
# (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch
|
||||
# range, making static arm binaries jump to garbage inside glibc's ifunc
|
||||
# resolution and crash before main() (issue #5738). Link 32-bit arm with LLD,
|
||||
# which emits correct addends.
|
||||
if [ "$(xx-info arch)" = "arm" ]; then
|
||||
export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'"
|
||||
fi
|
||||
if [ "$(xx-info os)" = "windows" ]; then
|
||||
export EXT=".exe"
|
||||
fi
|
||||
|
||||
BUILD_TAGS=$(./release/build-tags.sh)
|
||||
go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \
|
||||
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
|
||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||
-o /out/navidrome${EXT} .
|
||||
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
|
||||
./release/verify-binary.sh /out/navidrome*
|
||||
EOT
|
||||
|
||||
# Verify if the binary was built for the correct platform and it is statically linked
|
||||
@@ -174,6 +159,7 @@ ENV ND_MUSICFOLDER=/music
|
||||
ENV ND_DATAFOLDER=/data
|
||||
ENV ND_CONFIGFILE=/data/navidrome.toml
|
||||
ENV ND_PORT=4533
|
||||
ENV ND_ENABLEWEBPENCODING=true
|
||||
RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
|
||||
@@ -9,7 +9,7 @@ export ND_ENABLEINSIGHTSCOLLECTOR=false
|
||||
|
||||
ifneq ("$(wildcard .git/HEAD)","")
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||
else
|
||||
GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||
@@ -113,11 +113,10 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||
|
||||
gen: check_go_env ##@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
|
||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
|
||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
|
||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
|
||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
|
||||
go mod tidy -C plugins/pdk/go
|
||||
.PHONY: gen
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ A share of the revenue helps fund the development of Navidrome at no additional
|
||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
||||
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
|
||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
bytes "bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -97,32 +95,13 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
||||
}
|
||||
}
|
||||
|
||||
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
|
||||
// ahead of non-matches, prefer an exact-case match, then the most fans.
|
||||
rank := func(a Artist) int {
|
||||
switch {
|
||||
case a.Name == name:
|
||||
return 2
|
||||
case strings.EqualFold(a.Name, name):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
slices.SortFunc(artists, func(a, b Artist) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(rank(b), rank(a)),
|
||||
cmp.Compare(b.NbFan, a.NbFan),
|
||||
cmp.Compare(a.ID, b.ID),
|
||||
)
|
||||
})
|
||||
best := artists[0]
|
||||
if !strings.EqualFold(best.Name, name) {
|
||||
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
|
||||
// If the first one has the same name, that's the one
|
||||
if !strings.EqualFold(artists[0].Name, name) {
|
||||
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
|
||||
return new(best), nil
|
||||
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
||||
|
||||
@@ -34,66 +34,6 @@ var _ = Describe("deezerAgent", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("searchArtist", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *fakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
agent = &deezerAgent{
|
||||
dataStore: &tests.MockDataStore{},
|
||||
client: newClient(httpClient),
|
||||
}
|
||||
})
|
||||
|
||||
It("picks the exact-name match with the most fans when several share the name", func() {
|
||||
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":61045802,"name":"Queen","nb_fan":75},
|
||||
{"id":141954732,"name":"Queen","nb_fan":397},
|
||||
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
|
||||
{"id":183179807,"name":"Queen","nb_fan":53},
|
||||
{"id":412,"name":"Queen","nb_fan":12744378}
|
||||
],"total":5}`)),
|
||||
})
|
||||
|
||||
artist, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.ID).To(Equal(412))
|
||||
})
|
||||
|
||||
It("matches the name case-insensitively", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":1,"name":"QUEEN","nb_fan":10},
|
||||
{"id":2,"name":"queen","nb_fan":20}
|
||||
],"total":2}`)),
|
||||
})
|
||||
|
||||
artist, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.ID).To(Equal(2))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no result matches the name exactly", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
||||
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
|
||||
],"total":1}`)),
|
||||
})
|
||||
|
||||
_, err := agent.searchArtist(ctx, "Queen")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography - Language Fallback", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/djherbis/times"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -90,7 +91,8 @@ var _ = Describe("Extractor", func() {
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
return new(metadata.ToMediaFile(1, "folderID"))
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
return &mf
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -107,7 +109,7 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||
},
|
||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -118,8 +120,8 @@ var _ = Describe("Extractor", func() {
|
||||
DisplayTitle: "",
|
||||
Lang: code,
|
||||
Line: []model.Line{
|
||||
{Start: new(int64(0)), Value: "This is"},
|
||||
{Start: new(int64(2500)), Value: secondLine},
|
||||
{Start: gg.P(int64(0)), Value: "This is"},
|
||||
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
|
||||
@@ -231,9 +231,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
||||
res := make([]agents.Song, 0, len(resp))
|
||||
for _, t := range resp {
|
||||
res = append(res, agents.Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
Artist: t.Artist.Name,
|
||||
ArtistMBID: t.Artist.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
|
||||
@@ -309,11 +309,11 @@ var _ = Describe("lastfmAgent", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
||||
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
|
||||
{Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}},
|
||||
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
|
||||
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}},
|
||||
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
|
||||
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
|
||||
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
|
||||
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
|
||||
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
|
||||
|
||||
@@ -77,13 +77,6 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
linkToken, err := createLinkToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp["linkToken"] = linkToken
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -104,17 +97,11 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
linkToken, err := p.String("uid")
|
||||
uid, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
uid, err := verifyLinkToken(linkToken)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
|
||||
return
|
||||
}
|
||||
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("auth_router", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
userProps *tests.MockedUserPropsRepo
|
||||
httpClient *tests.FakeHttpClient
|
||||
router *Router
|
||||
)
|
||||
|
||||
const (
|
||||
victimID = "victim-user-id"
|
||||
attackerID = "attacker-user-id"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
userProps = &tests.MockedUserPropsRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedUserProps: userProps,
|
||||
}
|
||||
auth.Init(ds)
|
||||
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
router = &Router{
|
||||
ds: ds,
|
||||
apiKey: "API_KEY",
|
||||
secret: "SECRET",
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
router.client = newClient(router.apiKey, router.secret, httpClient)
|
||||
router.Handler = router.routes()
|
||||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
stubGetSessionOK := func(sessionKey string) {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
}
|
||||
|
||||
Describe("getLinkStatus", func() {
|
||||
It("includes a signed linkToken for the authenticated user", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link", nil)
|
||||
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.getLinkStatus(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
var body map[string]any
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
|
||||
Expect(body["apiKey"]).To(Equal("API_KEY"))
|
||||
Expect(body["status"]).To(Equal(false))
|
||||
token, ok := body["linkToken"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
verified, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(verified).To(Equal(victimID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("callback", func() {
|
||||
It("stores the session key under the user encoded in the signed token", func() {
|
||||
stubGetSessionOK("LEGIT_SESSION")
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
|
||||
})
|
||||
|
||||
It("rejects a raw (unsigned) uid value", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects an expired link token", func() {
|
||||
expiredToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
|
||||
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(storedSessionKey(attackerID)).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
|
||||
// An attacker holds a legitimate link token for their own account.
|
||||
// They attempt to call the callback hoping to overwrite the victim's
|
||||
// session key — but the handler must derive the user ID from the
|
||||
// signed token, not from any other input.
|
||||
stubGetSessionOK("ATTACKER_SESSION")
|
||||
attackerToken, err := createLinkToken(attackerID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
|
||||
Expect(storedSessionKey(victimID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns 400 when uid is missing", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 400 when token is missing", func() {
|
||||
linkToken, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.callback(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("link token helpers", func() {
|
||||
It("round-trips a freshly issued token", func() {
|
||||
token, err := createLinkToken(victimID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
uid, err := verifyLinkToken(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(uid).To(Equal(victimID))
|
||||
})
|
||||
|
||||
It("rejects garbage", func() {
|
||||
_, err := verifyLinkToken("not-a-jwt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a token whose scope claim is wrong", func() {
|
||||
wrongScopeToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": "some-other-scope",
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(wrongScopeToken)
|
||||
Expect(err).To(MatchError("invalid link token scope"))
|
||||
})
|
||||
|
||||
It("rejects a scoped token that has no expiration", func() {
|
||||
nonExpiringToken, err := auth.EncodeToken(map[string]any{
|
||||
"uid": victimID,
|
||||
"scope": linkTokenScope,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
linkTokenScope = "lastfm-link"
|
||||
linkTokenTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// createLinkToken issues a signed token binding the Last.fm callback to the
|
||||
// user who initiated the OAuth flow. It travels back through Last.fm via the
|
||||
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
|
||||
func createLinkToken(userID string) (string, error) {
|
||||
claims := map[string]any{
|
||||
"uid": userID,
|
||||
"scope": linkTokenScope,
|
||||
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
|
||||
}
|
||||
return auth.EncodeToken(claims)
|
||||
}
|
||||
|
||||
// verifyLinkToken validates a signed link token and returns the encoded user ID.
|
||||
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
|
||||
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
|
||||
// regular session JWT) from being accepted here.
|
||||
func verifyLinkToken(tokenStr string) (string, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// jwtauth treats a token without `exp` as non-expiring; require it
|
||||
// explicitly so an accidental regression cannot mint permanent tokens.
|
||||
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
|
||||
return "", errors.New("link token missing expiration")
|
||||
}
|
||||
var scope string
|
||||
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
|
||||
return "", errors.New("invalid link token scope")
|
||||
}
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err != nil || uid == "" {
|
||||
return "", errors.New("invalid link token user ID")
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
@@ -141,37 +141,24 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam
|
||||
|
||||
res := make([]agents.Song, len(resp))
|
||||
for i, t := range resp {
|
||||
mbid := ""
|
||||
if len(t.ArtistMBIDs) > 0 {
|
||||
mbid = t.ArtistMBIDs[0]
|
||||
}
|
||||
|
||||
res[i] = agents.Song{
|
||||
Album: t.ReleaseName,
|
||||
AlbumMBID: t.ReleaseMBID,
|
||||
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
|
||||
Duration: t.DurationMs,
|
||||
Name: t.RecordingName,
|
||||
MBID: t.RecordingMbid,
|
||||
Album: t.ReleaseName,
|
||||
AlbumMBID: t.ReleaseMBID,
|
||||
Artist: t.ArtistName,
|
||||
ArtistMBID: mbid,
|
||||
Duration: t.DurationMs,
|
||||
Name: t.RecordingName,
|
||||
MBID: t.RecordingMbid,
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// topSongArtists maps the top-recordings response, which carries a single combined display name
|
||||
// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not
|
||||
// positionally pairable, so the display name attaches to the first credit and any further MBIDs
|
||||
// become MBID-only collaborators — still valid identity signals for the matcher.
|
||||
func topSongArtists(name string, mbids []string) []agents.Artist {
|
||||
if len(mbids) == 0 {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return []agents.Artist{{Name: name}}
|
||||
}
|
||||
artists := make([]agents.Artist, len(mbids))
|
||||
artists[0] = agents.Artist{Name: name, MBID: mbids[0]}
|
||||
for i, m := range mbids[1:] {
|
||||
artists[i+1] = agents.Artist{MBID: m}
|
||||
}
|
||||
return artists
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
|
||||
if mbid == "" {
|
||||
return nil, agents.ErrNotFound
|
||||
@@ -216,7 +203,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||
songs[i] = agents.Song{
|
||||
Album: song.ReleaseName,
|
||||
AlbumMBID: song.ReleaseMBID,
|
||||
Artists: []agents.Artist{{Name: song.Artist}},
|
||||
Artist: song.Artist,
|
||||
MBID: song.MBID,
|
||||
Name: song.Name,
|
||||
}
|
||||
|
||||
@@ -249,22 +249,24 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artist: "Mili",
|
||||
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
},
|
||||
{
|
||||
ID: "",
|
||||
Name: "String Theocracy",
|
||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
||||
Album: "String Theocracy",
|
||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
||||
Duration: 174000,
|
||||
ID: "",
|
||||
Name: "String Theocracy",
|
||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
||||
Artist: "Mili",
|
||||
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||
Album: "String Theocracy",
|
||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
||||
Duration: 174000,
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -276,45 +278,17 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
ID: "",
|
||||
Name: "world.execute(me);",
|
||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||
Artist: "Mili",
|
||||
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||
Album: "Miracle Milk",
|
||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||
Duration: 211912,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() {
|
||||
body := `[{
|
||||
"recording_name": "Collab",
|
||||
"recording_mbid": "rec-1",
|
||||
"artist_name": "Drake feat. Future",
|
||||
"artist_mbids": ["mbid-drake", "mbid-future"],
|
||||
"release_name": "Album",
|
||||
"release_mbid": "rel-1",
|
||||
"length": 200000
|
||||
}]`
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(HaveLen(1))
|
||||
Expect(data[0].Artists).To(Equal([]agents.Artist{
|
||||
{Name: "Drake feat. Future", MBID: "mbid-drake"},
|
||||
{MBID: "mbid-future"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("leaves Artists nil when the top song carries no name or MBIDs", func() {
|
||||
body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]`
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(data).To(HaveLen(1))
|
||||
Expect(data[0].Artists).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarArtists", func() {
|
||||
@@ -419,24 +393,26 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
||||
Expect(resp).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artist: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
{
|
||||
ID: "",
|
||||
Name: "Wake Me Up Before You Go‐Go",
|
||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
||||
ISRC: "",
|
||||
Artists: []agents.Artist{{Name: "Wham!"}},
|
||||
Album: "Make It Big",
|
||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
||||
Duration: 0,
|
||||
ID: "",
|
||||
Name: "Wake Me Up Before You Go‐Go",
|
||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
||||
ISRC: "",
|
||||
Artist: "Wham!",
|
||||
ArtistMBID: "",
|
||||
Album: "Make It Big",
|
||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
||||
Duration: 0,
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -451,14 +427,15 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
||||
Expect(resp).To(Equal([]agents.Song{
|
||||
{
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
ID: "",
|
||||
Name: "Take On Me",
|
||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||
ISRC: "",
|
||||
Artist: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ var (
|
||||
|
||||
func runBackup(ctx context.Context) {
|
||||
if backupDir != "" {
|
||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||
conf.Server.Backup.Path = backupDir
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||
@@ -104,7 +104,7 @@ func runBackup(ctx context.Context) {
|
||||
|
||||
func runPrune(ctx context.Context) {
|
||||
if backupDir != "" {
|
||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||
conf.Server.Backup.Path = backupDir
|
||||
}
|
||||
|
||||
if backupCount != -1 {
|
||||
|
||||
+3
-3
@@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var marshalers = map[string]func(any) ([]byte, error){
|
||||
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||
"pretty": prettyMarshal,
|
||||
"toml": toml.Marshal,
|
||||
"yaml": yaml.Marshal,
|
||||
"json": json.Marshal,
|
||||
"jsonindent": func(v any) ([]byte, error) {
|
||||
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
},
|
||||
}
|
||||
|
||||
func prettyMarshal(v any) ([]byte, error) {
|
||||
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||
out := v.([]core.InspectOutput)
|
||||
var res strings.Builder
|
||||
for i := range out {
|
||||
|
||||
-558
@@ -1,558 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pluginManager is the subset of *plugins.Manager the CLI needs.
|
||||
type pluginManager interface {
|
||||
EnablePlugin(ctx context.Context, id string) error
|
||||
DisablePlugin(ctx context.Context, id string) error
|
||||
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
|
||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
RescanPlugins(ctx context.Context) error
|
||||
}
|
||||
|
||||
var (
|
||||
pluginListFormat string
|
||||
pluginInfoFormat string
|
||||
)
|
||||
|
||||
var (
|
||||
editConfig string
|
||||
editConfigFile string
|
||||
editUsers string
|
||||
editAllUsers bool
|
||||
editLibraries string
|
||||
editAllLibs bool
|
||||
editWriteAccess bool
|
||||
editNoWrite bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(pluginRoot)
|
||||
|
||||
pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]")
|
||||
pluginRoot.AddCommand(pluginListCmd)
|
||||
pluginRoot.AddCommand(pluginEnableCmd)
|
||||
pluginRoot.AddCommand(pluginDisableCmd)
|
||||
|
||||
pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON")
|
||||
pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file")
|
||||
pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`)
|
||||
pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users")
|
||||
pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`)
|
||||
pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries")
|
||||
pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries")
|
||||
pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries")
|
||||
pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access")
|
||||
pluginRoot.AddCommand(pluginEditCmd)
|
||||
|
||||
pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]")
|
||||
pluginRoot.AddCommand(pluginInfoCmd)
|
||||
pluginRoot.AddCommand(pluginValidateCmd)
|
||||
pluginRoot.AddCommand(pluginRescanCmd)
|
||||
}
|
||||
|
||||
var (
|
||||
pluginRoot = &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage and inspect plugins",
|
||||
Long: "List, inspect, enable, disable, configure, rescan, and validate plugins",
|
||||
}
|
||||
|
||||
pluginListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed plugins",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginList(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
pluginEnableCmd = &cobra.Command{
|
||||
Use: "enable <id>",
|
||||
Short: "Enable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := enablePlugin(ctx, mgr, args[0]); err != nil {
|
||||
log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
pluginDisableCmd = &cobra.Command{
|
||||
Use: "disable <id>",
|
||||
Short: "Disable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := disablePlugin(ctx, mgr, args[0]); err != nil {
|
||||
log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
pluginInfoCmd = &cobra.Command{
|
||||
Use: "info <id|file.ndp>",
|
||||
Short: "Show details for an installed plugin or a .ndp package",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginInfo(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
|
||||
pluginValidateCmd = &cobra.Command{
|
||||
Use: "validate <id|file.ndp>",
|
||||
Short: "Validate an installed plugin or a .ndp package manifest",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runPluginValidate(cmd.Context(), args[0])
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// isPackagePath checks only the extension (not existence) so a mistyped path
|
||||
// still routes to ReadManifest, which reports a precise "no such file" error.
|
||||
func isPackagePath(arg string) bool {
|
||||
return strings.HasSuffix(arg, plugins.PackageExtension)
|
||||
}
|
||||
|
||||
func formatPluginInfo(p *model.Plugin, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "text":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
||||
}
|
||||
name, version := manifestSummary(*p)
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "ID: %s\n", p.ID)
|
||||
fmt.Fprintf(&sb, "Name: %s\n", name)
|
||||
fmt.Fprintf(&sb, "Version: %s\n", version)
|
||||
fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled)
|
||||
fmt.Fprintf(&sb, "Path: %s\n", p.Path)
|
||||
fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256)
|
||||
fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers)
|
||||
fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries)
|
||||
fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess)
|
||||
if p.Users != "" {
|
||||
fmt.Fprintf(&sb, "Users: %s\n", p.Users)
|
||||
}
|
||||
if p.Libraries != "" {
|
||||
fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries)
|
||||
}
|
||||
if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil {
|
||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
||||
}
|
||||
}
|
||||
if !p.CreatedAt.IsZero() {
|
||||
fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339))
|
||||
}
|
||||
if !p.UpdatedAt.IsZero() {
|
||||
fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339))
|
||||
}
|
||||
if p.Config != "" {
|
||||
fmt.Fprintf(&sb, "Config: %s\n", p.Config)
|
||||
}
|
||||
if p.LastError != "" {
|
||||
fmt.Fprintf(&sb, "Last error: %s\n", p.LastError)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(struct {
|
||||
*plugins.Manifest
|
||||
SHA256 string `json:"sha256"`
|
||||
}{m, sha256}, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "text":
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
||||
}
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "Name: %s\n", m.Name)
|
||||
fmt.Fprintf(&sb, "Version: %s\n", m.Version)
|
||||
fmt.Fprintf(&sb, "Author: %s\n", m.Author)
|
||||
if m.Description != nil {
|
||||
fmt.Fprintf(&sb, "Description: %s\n", *m.Description)
|
||||
}
|
||||
if m.Website != nil {
|
||||
fmt.Fprintf(&sb, "Website: %s\n", *m.Website)
|
||||
}
|
||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
||||
}
|
||||
fmt.Fprintf(&sb, "SHA256: %s\n", sha256)
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func runPluginInfo(ctx context.Context, arg string) {
|
||||
if isPackagePath(arg) {
|
||||
m, err := plugins.ReadManifest(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read package", "path", arg, err)
|
||||
}
|
||||
sha, err := plugins.ComputeFileSHA256(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to hash package", "path", arg, err)
|
||||
}
|
||||
out, err := formatManifestInfo(m, sha, pluginInfoFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
return
|
||||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
out, err := formatPluginInfo(p, pluginInfoFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
|
||||
func runPluginValidate(ctx context.Context, arg string) {
|
||||
if isPackagePath(arg) {
|
||||
if _, err := plugins.ReadManifest(arg); err != nil {
|
||||
log.Fatal(ctx, "Validation failed", "path", arg, err)
|
||||
}
|
||||
fmt.Printf("%s: OK\n", arg)
|
||||
return
|
||||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil {
|
||||
log.Fatal(ctx, "Validation failed", "id", arg, err)
|
||||
}
|
||||
if p.Config != "" {
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil {
|
||||
log.Fatal(ctx, "Config validation failed", "id", arg, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s: OK\n", arg)
|
||||
}
|
||||
|
||||
// manifestSummary extracts the display name and version from a stored manifest JSON, falling
|
||||
// back to the plugin ID when the manifest can't be parsed.
|
||||
func manifestSummary(p model.Plugin) (name, version string) {
|
||||
var m struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil {
|
||||
return p.ID, ""
|
||||
}
|
||||
return m.Name, m.Version
|
||||
}
|
||||
|
||||
func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||
switch format {
|
||||
case "json":
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
case "csv":
|
||||
var sb strings.Builder
|
||||
w := csv.NewWriter(&sb)
|
||||
_ = w.Write([]string{"id", "name", "version", "enabled", "last error"})
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
_ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError})
|
||||
}
|
||||
w.Flush()
|
||||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError)
|
||||
}
|
||||
w.Flush()
|
||||
return sb.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format)
|
||||
}
|
||||
}
|
||||
|
||||
func runPluginList(ctx context.Context) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
out, err := formatPluginList(list, pluginListFormat)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to format output", err)
|
||||
}
|
||||
fmt.Print(out)
|
||||
}
|
||||
|
||||
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
|
||||
// inspection deliberately skips this so it works without a configured server.
|
||||
func requirePluginsEnabled(ctx context.Context) {
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
|
||||
}
|
||||
}
|
||||
|
||||
func enablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
||||
return mgr.EnablePlugin(ctx, id)
|
||||
}
|
||||
|
||||
func disablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
||||
return mgr.DisablePlugin(ctx, id)
|
||||
}
|
||||
|
||||
type pluginEditOptions struct {
|
||||
config *string // nil = leave unchanged
|
||||
users *string
|
||||
allUsers *bool
|
||||
libraries *string
|
||||
allLibraries *bool
|
||||
writeAccess *bool
|
||||
}
|
||||
|
||||
var pluginEditCmd = &cobra.Command{
|
||||
Use: "edit <id>",
|
||||
Short: "Update a plugin's config and/or permissions",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
ds, ctx := getAdminContext(cmd.Context())
|
||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
mgr := GetPluginManager(ctx)
|
||||
opts := buildEditOptionsFromFlags(ctx, cmd)
|
||||
if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil {
|
||||
log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions {
|
||||
var opts pluginEditOptions
|
||||
switch {
|
||||
case cmd.Flags().Changed("config"):
|
||||
c := editConfig
|
||||
opts.config = &c
|
||||
case cmd.Flags().Changed("config-file"):
|
||||
c := readConfigFile(ctx, editConfigFile)
|
||||
opts.config = &c
|
||||
}
|
||||
if cmd.Flags().Changed("users") {
|
||||
u := editUsers
|
||||
opts.users = &u
|
||||
}
|
||||
if cmd.Flags().Changed("all-users") {
|
||||
v := editAllUsers
|
||||
opts.allUsers = &v
|
||||
}
|
||||
if cmd.Flags().Changed("libraries") {
|
||||
l := editLibraries
|
||||
opts.libraries = &l
|
||||
}
|
||||
if cmd.Flags().Changed("all-libraries") {
|
||||
v := editAllLibs
|
||||
opts.allLibraries = &v
|
||||
}
|
||||
if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") {
|
||||
// write-access is part of the library-permission group, so it is updated
|
||||
// alongside the (preserved) library list rather than on its own.
|
||||
wa := editWriteAccess && !editNoWrite
|
||||
opts.writeAccess = &wa
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func readConfigFile(ctx context.Context, path string) string {
|
||||
var data []byte
|
||||
var err error
|
||||
if path == "-" {
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
data, err = os.ReadFile(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read config file", "path", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// applyPluginEdit applies the requested changes on top of the plugin's current
|
||||
// state. Like the native API, it reads the existing users/libraries before
|
||||
// updating so that flipping one flag (e.g. --write-access) does not wipe
|
||||
// unspecified fields, and rejects non-JSON users/libraries values.
|
||||
func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error {
|
||||
if opts.config == nil && opts.users == nil && opts.allUsers == nil &&
|
||||
opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil {
|
||||
return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access")
|
||||
}
|
||||
id := cur.ID
|
||||
if opts.config != nil {
|
||||
if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.users != nil || opts.allUsers != nil {
|
||||
users, allUsers := cur.Users, cur.AllUsers
|
||||
if opts.users != nil {
|
||||
parsed, err := usersToJSON(*opts.users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users = parsed
|
||||
allUsers = false // an explicit list means "restrict to these users"
|
||||
}
|
||||
if opts.allUsers != nil {
|
||||
allUsers = *opts.allUsers
|
||||
}
|
||||
if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil {
|
||||
libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess
|
||||
if opts.libraries != nil {
|
||||
parsed, err := librariesToJSON(*opts.libraries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
libs = parsed
|
||||
allLibs = false // an explicit list means "restrict to these libraries"
|
||||
}
|
||||
if opts.allLibraries != nil {
|
||||
allLibs = *opts.allLibraries
|
||||
}
|
||||
if opts.writeAccess != nil {
|
||||
writeAccess = *opts.writeAccess
|
||||
}
|
||||
if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated
|
||||
// list and returns the JSON-array form the manager stores.
|
||||
func usersToJSON(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
||||
if !json.Valid([]byte(value)) {
|
||||
return "", fmt.Errorf("invalid JSON in --users")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
var names []string
|
||||
for _, u := range strings.Split(value, ",") {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
names = append(names, u)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(names)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// librariesToJSON accepts either a JSON array (starts with '[') or a
|
||||
// comma-separated list of integer IDs and returns the JSON-array form stored.
|
||||
func librariesToJSON(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
||||
if !json.Valid([]byte(value)) {
|
||||
return "", fmt.Errorf("invalid JSON in --libraries")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
ids := []int{}
|
||||
for _, l := range strings.Split(value, ",") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
id, err := strconv.Atoi(l)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid library ID %q: must be an integer", l)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(ids)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var pluginRescanCmd = &cobra.Command{
|
||||
Use: "rescan",
|
||||
Short: "Re-discover plugins in the plugins folder",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
_, ctx := getAdminContext(cmd.Context())
|
||||
mgr := GetPluginManager(ctx)
|
||||
if err := rescanPlugins(ctx, mgr); err != nil {
|
||||
log.Fatal(ctx, "Failed to rescan plugins", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func rescanPlugins(ctx context.Context, mgr pluginManager) error {
|
||||
return mgr.RescanPlugins(ctx)
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var samplePlugins = model.Plugins{
|
||||
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
|
||||
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
|
||||
}
|
||||
|
||||
var _ = Describe("plugin command format flags", func() {
|
||||
// Regression: list and info must not share a format variable. Binding the
|
||||
// same var on both commands makes the last init() registration clobber the
|
||||
// other's default, breaking `plugin list` with no -f flag.
|
||||
It("defaults `list -f` to table", func() {
|
||||
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
|
||||
})
|
||||
|
||||
It("defaults `info -f` to text", func() {
|
||||
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginList", func() {
|
||||
It("renders csv with a header and one row per plugin", func() {
|
||||
out, err := formatPluginList(samplePlugins, "csv")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
Expect(lines).To(HaveLen(3)) // header + 2 rows
|
||||
Expect(lines[0]).To(ContainSubstring("id"))
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
Expect(out).To(ContainSubstring("beta"))
|
||||
})
|
||||
|
||||
It("renders valid json", func() {
|
||||
out, err := formatPluginList(samplePlugins, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var got []map[string]any
|
||||
Expect(json.Unmarshal([]byte(out), &got)).To(Succeed())
|
||||
Expect(got).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("renders a human table by default", func() {
|
||||
out, err := formatPluginList(samplePlugins, "table")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("Alpha"))
|
||||
Expect(out).To(ContainSubstring("1.0.0"))
|
||||
})
|
||||
|
||||
It("errors on an unknown format", func() {
|
||||
_, err := formatPluginList(samplePlugins, "yaml")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("enable/disable plugin", func() {
|
||||
It("calls EnablePlugin on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := enablePlugin(context.Background(), mgr, "alpha")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
|
||||
})
|
||||
|
||||
It("calls DisablePlugin on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := disablePlugin(context.Background(), mgr, "beta")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("applyPluginEdit", func() {
|
||||
var cur *model.Plugin
|
||||
BeforeEach(func() {
|
||||
cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true}
|
||||
})
|
||||
|
||||
It("validates then updates config when config is provided", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cfg := `{"key":"val"}`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
||||
Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
||||
})
|
||||
|
||||
It("updates users with allUsers flag", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
||||
pluginEditOptions{allUsers: &all})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updates libraries with allLibraries and write access", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
wr := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
||||
pluginEditOptions{allLibraries: &all, writeAccess: &wr})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue())
|
||||
})
|
||||
|
||||
It("preserves existing fields when only the write-access flag changes", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
no := false
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse())
|
||||
})
|
||||
|
||||
It("preserves existing users when only the all-users flag changes", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
all := true
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped
|
||||
})
|
||||
|
||||
It("parses a comma-separated users value into a JSON array", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := "alice, bob"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
||||
})
|
||||
|
||||
It("passes a JSON-array users value through unchanged", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := `["alice","bob"]`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
||||
})
|
||||
|
||||
It("rejects a malformed JSON-array users value", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
users := `["alice"`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("parses a comma-separated libraries value into a JSON array of ints", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
libs := "1, 2"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`))
|
||||
})
|
||||
|
||||
It("rejects a non-integer library ID", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
libs := "1,abc"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("clears allUsers when an explicit users list is set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cur.AllUsers = true
|
||||
users := "alice"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`))
|
||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse())
|
||||
})
|
||||
|
||||
It("clears allLibraries when an explicit libraries list is set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
cur.AllLibraries = true
|
||||
libs := "1"
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`))
|
||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does nothing and errors when no fields are set", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("aborts the config update when validation fails", func() {
|
||||
mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")}
|
||||
cfg := `{"key":"val"}`
|
||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
||||
Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("isPackagePath", func() {
|
||||
It("is true for any .ndp path", func() {
|
||||
Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue())
|
||||
})
|
||||
It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() {
|
||||
Expect(isPackagePath("/nope/x.ndp")).To(BeTrue())
|
||||
})
|
||||
It("is false for a bare plugin id", func() {
|
||||
Expect(isPackagePath("my-plugin")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginInfo", func() {
|
||||
It("renders installed plugin details as text", func() {
|
||||
p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
Expect(out).To(ContainSubstring("Alpha"))
|
||||
})
|
||||
It("renders json", func() {
|
||||
p := &model.Plugin{ID: "alpha", Manifest: `{}`}
|
||||
out, err := formatPluginInfo(p, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alpha"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatManifestInfo", func() {
|
||||
It("renders text with name, version, author", func() {
|
||||
m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("My Plugin"))
|
||||
Expect(out).To(ContainSubstring("2.0.0"))
|
||||
Expect(out).To(ContainSubstring("me"))
|
||||
})
|
||||
|
||||
It("omits Description and Website when nil", func() {
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Description:"))
|
||||
Expect(out).ToNot(ContainSubstring("Website:"))
|
||||
})
|
||||
|
||||
It("includes Description and Website when set", func() {
|
||||
desc := "a cool plugin"
|
||||
site := "https://example.com"
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("a cool plugin"))
|
||||
Expect(out).To(ContainSubstring("https://example.com"))
|
||||
})
|
||||
|
||||
It("renders valid json", func() {
|
||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("\"name\""))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatPluginInfo enriched text", func() {
|
||||
var fixedTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
})
|
||||
|
||||
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
|
||||
p := &model.Plugin{
|
||||
ID: "myplugin",
|
||||
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
|
||||
Enabled: true,
|
||||
Users: "alice,bob",
|
||||
Libraries: "1,2",
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime.Add(time.Hour),
|
||||
}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("alice,bob"))
|
||||
Expect(out).To(ContainSubstring("1,2"))
|
||||
Expect(out).To(ContainSubstring("subsonicapi"))
|
||||
Expect(out).To(ContainSubstring("users"))
|
||||
Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z"))
|
||||
})
|
||||
|
||||
It("omits Users and Libraries lines when empty", func() {
|
||||
p := &model.Plugin{
|
||||
ID: "myplugin",
|
||||
Manifest: `{"name":"X","version":"1.0.0","author":"me"}`,
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime,
|
||||
}
|
||||
out, err := formatPluginInfo(p, "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Users:"))
|
||||
Expect(out).ToNot(ContainSubstring("Libraries:"))
|
||||
})
|
||||
|
||||
It("does not alter json output", func() {
|
||||
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
|
||||
out, err := formatPluginInfo(p, "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring(`"x"`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatManifestInfo enriched text", func() {
|
||||
It("includes Permissions when declared", func() {
|
||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring("http"))
|
||||
Expect(out).To(ContainSubstring("Permissions:"))
|
||||
})
|
||||
|
||||
It("omits Permissions line when no permissions declared", func() {
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
|
||||
out, err := formatManifestInfo(m, "abc123", "text")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).ToNot(ContainSubstring("Permissions:"))
|
||||
})
|
||||
|
||||
It("does not alter json output", func() {
|
||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
||||
out, err := formatManifestInfo(m, "abc123", "json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(ContainSubstring(`"name"`))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("rescanPlugins", func() {
|
||||
It("calls RescanPlugins on the manager", func() {
|
||||
mgr := &tests.MockPluginManager{}
|
||||
err := rescanPlugins(context.Background(), mgr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mgr.RescanPluginsCalls).To(Equal(1))
|
||||
})
|
||||
})
|
||||
+7
-76
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -87,11 +86,8 @@ func runNavidrome(ctx context.Context) {
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
g.Go(scheduleDBOptimizer(ctx))
|
||||
g.Go(startPluginManager(ctx))
|
||||
artworkWorker := CreateArtworkWorker()
|
||||
g.Go(startArtworkWorker(ctx, artworkWorker))
|
||||
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
||||
g.Go(runInitialScan(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
g.Go(startScanWatcher(ctx))
|
||||
@@ -128,9 +124,6 @@ func startServer(ctx context.Context) func() error {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||
}
|
||||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
@@ -282,24 +275,16 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
||||
func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
if !conf.Server.EnableScheduledDBAnalyze {
|
||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
|
||||
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
if !ok {
|
||||
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
|
||||
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||
if scanner.IsScanning() {
|
||||
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
db.Optimize(ctx)
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -348,60 +333,6 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
// startArtworkWorker starts the background artwork acquisition worker. It always
|
||||
// runs; the queue is simply empty until something enqueues work into it.
|
||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Starting artwork worker")
|
||||
return worker.Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
|
||||
// recurring stale-absent recheck and prune jobs. Scan-triggered prune lands in a later phase.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
ds := CreateDataStore()
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
|
||||
if err := artwork.EnqueueStaleAbsentAll(ctx, ds); err != nil {
|
||||
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
||||
}
|
||||
|
||||
backfilled, err := artwork.Backfill(ctx, ds)
|
||||
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():
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startPluginManager starts the plugin manager, if configured.
|
||||
func startPluginManager(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
|
||||
+3
-34
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -44,20 +43,15 @@ var scanCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
|
||||
var changesDetected bool
|
||||
var scanErrors []error
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
for status := range pl.ReadOrDone(ctx, progress) {
|
||||
if status.Warning != "" {
|
||||
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
||||
}
|
||||
if status.Error != "" {
|
||||
log.Error(ctx, "Scan error", "error", status.Error)
|
||||
scanErrors = append(scanErrors, errors.New(status.Error))
|
||||
}
|
||||
if status.ChangesDetected {
|
||||
changesDetected = true
|
||||
}
|
||||
// Discard the progress status, we only care about errors
|
||||
}
|
||||
|
||||
if fullScan {
|
||||
@@ -65,7 +59,6 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
|
||||
} else {
|
||||
log.Info("Finished rescan")
|
||||
}
|
||||
return changesDetected, errors.Join(scanErrors...)
|
||||
}
|
||||
|
||||
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
@@ -102,16 +95,6 @@ func runScanner(ctx context.Context) {
|
||||
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
||||
}
|
||||
|
||||
effectiveFullScan := fullScan
|
||||
if !subprocess {
|
||||
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
|
||||
if effectiveFullScan {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to scan", err)
|
||||
@@ -121,21 +104,7 @@ func runScanner(ctx context.Context) {
|
||||
if subprocess {
|
||||
trackScanAsSubprocess(ctx, progress)
|
||||
} else {
|
||||
changesDetected, scanErr := trackScanInteractively(ctx, progress)
|
||||
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
|
||||
if changesDetected {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
if effectiveFullScan && scanErr == nil {
|
||||
if err := db.Optimize(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
trackScanInteractively(ctx, progress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("trackScanInteractively", func() {
|
||||
It("reports changes and scan errors", func() {
|
||||
progress := make(chan *scanner.ProgressInfo, 2)
|
||||
progress <- &scanner.ProgressInfo{ChangesDetected: true}
|
||||
progress <- &scanner.ProgressInfo{Error: "scan failed"}
|
||||
close(progress)
|
||||
|
||||
changesDetected, err := trackScanInteractively(context.Background(), progress)
|
||||
Expect(changesDetected).To(BeTrue())
|
||||
Expect(err).To(MatchError("scan failed"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("readTargetsFromFile", func() {
|
||||
var tempDir string
|
||||
|
||||
|
||||
+16
-15
@@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
options["SystemdScript"] = systemdScript
|
||||
if conf.Server.LogFile != "" {
|
||||
options["LogOutput"] = false
|
||||
} else {
|
||||
options["LogOutput"] = true
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
}
|
||||
svcConfig := &service.Config{
|
||||
UserName: installUser,
|
||||
@@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
|
||||
println("Installing service with:")
|
||||
println(" working directory: " + executablePath())
|
||||
println(" music folder: " + conf.Server.MusicFolder)
|
||||
println(" data folder: " + conf.Server.DataFolder.String())
|
||||
println(" data folder: " + conf.Server.DataFolder)
|
||||
if conf.Server.LogFile != "" {
|
||||
println(" log file: " + conf.Server.LogFile)
|
||||
} else {
|
||||
println(" logs folder: " + conf.Server.DataFolder.String())
|
||||
println(" logs folder: " + conf.Server.DataFolder)
|
||||
}
|
||||
if cfgFile != "" {
|
||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||
@@ -232,21 +232,22 @@ func buildExecuteCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
const systemdScript = `[Unit]
|
||||
Description={{Description}}
|
||||
ConditionFileIsExecutable={{Path | cmdEscape}}
|
||||
{{range Dependencies}}{{.}}
|
||||
{{end}}
|
||||
Description={{.Description}}
|
||||
ConditionFileIsExecutable={{.Path|cmdEscape}}
|
||||
{{range $i, $dep := .Dependencies}}
|
||||
{{$dep}} {{end}}
|
||||
|
||||
[Service]
|
||||
StartLimitInterval=5
|
||||
StartLimitBurst=10
|
||||
ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
|
||||
{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}}
|
||||
{{if UserName}}User={{UserName}}{{end}}
|
||||
{{if Restart}}Restart={{Restart}}{{end}}
|
||||
{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}}
|
||||
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||
{{if .UserName}}User={{.UserName}}{{end}}
|
||||
{{if .Restart}}Restart={{.Restart}}{{end}}
|
||||
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
|
||||
TimeoutStopSec=20
|
||||
RestartSec=120
|
||||
EnvironmentFile=-/etc/sysconfig/{{Name}}
|
||||
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
||||
|
||||
DevicePolicy=closed
|
||||
@@ -259,7 +260,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||
{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}}
|
||||
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||
ProtectSystem=full
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("systemdScript template", func() {
|
||||
systemdKeys := map[string]bool{
|
||||
"Description": true, "Path": true, "Name": true, "Dependencies": true,
|
||||
"Arguments": true, "ChRoot": true, "WorkingDirectory": true,
|
||||
"UserName": true, "ReloadSignal": true, "PIDFile": true,
|
||||
"LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true,
|
||||
"Restart": true, "SuccessExitStatus": true, "EnvVars": true,
|
||||
}
|
||||
systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true}
|
||||
|
||||
actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`)
|
||||
|
||||
parseAction := func(action string) (key string, funcs []string) {
|
||||
action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-"))
|
||||
kw, rest, _ := strings.Cut(action, " ")
|
||||
switch kw {
|
||||
case "end", "else":
|
||||
return "", nil
|
||||
case "if", "range":
|
||||
return strings.TrimSpace(rest), nil
|
||||
}
|
||||
parts := strings.Split(action, "|")
|
||||
for _, p := range parts[1:] {
|
||||
funcs = append(funcs, strings.TrimSpace(p))
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), funcs
|
||||
}
|
||||
|
||||
It("only references keys and functions the service library provides", func() {
|
||||
matches := actionRe.FindAllStringSubmatch(systemdScript, -1)
|
||||
Expect(matches).ToNot(BeEmpty())
|
||||
|
||||
for _, m := range matches {
|
||||
key, funcs := parseAction(m[1])
|
||||
if key != "" && key != "." {
|
||||
Expect(systemdKeys).To(HaveKey(key),
|
||||
"template action %q uses a key unknown to kardianos/service", m[0])
|
||||
}
|
||||
for _, fn := range funcs {
|
||||
Expect(systemdFuncs).To(HaveKey(fn),
|
||||
"template action %q uses an unknown pipeline function", m[0])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
+2
-43
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
@@ -110,38 +109,13 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
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)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
players := core.NewPlayers(dataStore)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
imageUploadService := core.NewImageUploadService()
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
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)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
@@ -236,21 +210,6 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
func CreateArtworkWorker() *artwork.Worker {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
imageStore := artwork.ProvideImageStore()
|
||||
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)
|
||||
fFmpeg := ffmpeg.New()
|
||||
worker := artwork.NewWorker(dataStore, imageStore, provider, fFmpeg)
|
||||
return worker
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
@@ -262,7 +221,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)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.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(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)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
@@ -34,7 +33,6 @@ var allProviders = wire.NewSet(
|
||||
artwork.Set,
|
||||
server.New,
|
||||
subsonic.New,
|
||||
jellyfin.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
@@ -51,7 +49,6 @@ var allProviders = wire.NewSet(
|
||||
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)),
|
||||
@@ -82,12 +79,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
@@ -136,12 +127,6 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
))
|
||||
}
|
||||
|
||||
func CreateArtworkWorker() *artwork.Worker {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
||||
@@ -2,7 +2,9 @@ package configtest
|
||||
|
||||
import "github.com/navidrome/navidrome/conf"
|
||||
|
||||
// TODO Remove this redirection and call SnapshotConfig directly from tests
|
||||
func SetupConfig() func() {
|
||||
return conf.SnapshotConfig()
|
||||
oldValues := *conf.Server
|
||||
return func() {
|
||||
conf.Server = &oldValues
|
||||
}
|
||||
}
|
||||
+52
-107
@@ -2,7 +2,6 @@ package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/go-viper/encoding/ini"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -31,8 +29,8 @@ type configOptions struct {
|
||||
UnixSocketPerm string
|
||||
EnforceNonRootUser bool
|
||||
MusicFolder string
|
||||
DataFolder Dir
|
||||
CacheFolder Dir
|
||||
DataFolder string
|
||||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
LogFile string
|
||||
@@ -47,18 +45,16 @@ type configOptions struct {
|
||||
UIWelcomeMessage string
|
||||
MaxSidebarPlaylists int
|
||||
EnableTranscodingConfig bool
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
EnableInsightsCollector bool
|
||||
EnableScheduledDBAnalyze bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
ImageCacheSize string
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
ArtworkWorkerConcurrency int
|
||||
ArtworkExternalMaxRPS int
|
||||
AutoImportPlaylists bool
|
||||
DefaultPlaylistPublicVisibility bool
|
||||
PlaylistsPath string
|
||||
@@ -115,11 +111,9 @@ type configOptions struct {
|
||||
PID pidOptions `json:",omitzero"`
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
Transcoding transcodingOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Deezer deezerOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
Jellyfin jellyfinOptions `json:",omitzero"`
|
||||
EnableScrobbleHistory bool
|
||||
Tags map[string]TagConf `json:",omitempty"`
|
||||
Agents string
|
||||
@@ -140,7 +134,6 @@ type configOptions struct {
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtworkThrottleBuffered bool
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
@@ -151,29 +144,22 @@ type configOptions struct {
|
||||
DevEnablePluginsInsights bool
|
||||
DevPluginCompilationTimeout time.Duration
|
||||
DevExternalArtistFetchMultiplier float64
|
||||
DevOptimizeDB bool
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
Enabled bool
|
||||
Schedule string
|
||||
WatcherWait time.Duration
|
||||
ScanOnStartup bool
|
||||
Extractor string
|
||||
ArtistJoiner string
|
||||
ArtistSplitExceptions []string // Artist names never split by tag separators
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type transcodingOptions struct {
|
||||
MaxConcurrent int
|
||||
MaxConcurrentPerUser int
|
||||
EnableCancellation bool
|
||||
Enabled bool
|
||||
Schedule string
|
||||
WatcherWait time.Duration
|
||||
ScanOnStartup bool
|
||||
Extractor string
|
||||
ArtistJoiner string
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
@@ -221,18 +207,6 @@ type listenBrainzOptions struct {
|
||||
TrackAlgorithm string
|
||||
}
|
||||
|
||||
type jellyfinOptions struct {
|
||||
Enabled bool
|
||||
ServerName string
|
||||
// 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
|
||||
// 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.
|
||||
MaxConcurrentStreams int
|
||||
}
|
||||
|
||||
type httpHeaderOptions struct {
|
||||
FrameOptions string
|
||||
}
|
||||
@@ -254,7 +228,7 @@ type jukeboxOptions struct {
|
||||
|
||||
type backupOptions struct {
|
||||
Count int
|
||||
Path Dir
|
||||
Path string
|
||||
Schedule string
|
||||
}
|
||||
|
||||
@@ -272,7 +246,7 @@ type inspectOptions struct {
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder Dir
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
@@ -312,22 +286,6 @@ var (
|
||||
hooks []func()
|
||||
)
|
||||
|
||||
// SnapshotConfig returns a function that restores Server to its current state.
|
||||
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
|
||||
func SnapshotConfig() func() {
|
||||
snapshot, err := json.Marshal(Server)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
|
||||
}
|
||||
return func() {
|
||||
var restored configOptions
|
||||
if err := json.Unmarshal(snapshot, &restored); err != nil {
|
||||
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
|
||||
}
|
||||
Server = &restored
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFromFile(confFile string) {
|
||||
viper.SetConfigFile(confFile)
|
||||
err := viper.ReadInConfig()
|
||||
@@ -347,17 +305,8 @@ func Load(noConfigDump bool) {
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
mapDeprecatedOption("DevArtworkWorkerConcurrency", "ArtworkWorkerConcurrency")
|
||||
mapDeprecatedOption("DevArtworkExternalRPS", "ArtworkExternalMaxRPS")
|
||||
|
||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
),
|
||||
))
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
logFatal("Error parsing config:", err)
|
||||
}
|
||||
@@ -367,28 +316,48 @@ func Load(noConfigDump bool) {
|
||||
logFatal(err)
|
||||
}
|
||||
|
||||
if Server.CacheFolder.String() == "" {
|
||||
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
|
||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
logFatal("Error creating data path:", err)
|
||||
}
|
||||
|
||||
if Server.CacheFolder == "" {
|
||||
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
|
||||
}
|
||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
logFatal("Error creating cache path:", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||
if err != nil {
|
||||
logFatal("Error creating artwork path:", err)
|
||||
}
|
||||
|
||||
if Server.Plugins.Enabled {
|
||||
if Server.Plugins.Folder.String() == "" {
|
||||
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
|
||||
} else {
|
||||
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
|
||||
if Server.Plugins.Folder == "" {
|
||||
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||
}
|
||||
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||
if err != nil {
|
||||
logFatal("Error creating plugins path:", err)
|
||||
}
|
||||
}
|
||||
|
||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||
if Server.DbPath == "" {
|
||||
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
|
||||
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||
}
|
||||
|
||||
if Server.Backup.Path != "" {
|
||||
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||
if err != nil {
|
||||
logFatal("Error creating backup path:", err)
|
||||
}
|
||||
}
|
||||
|
||||
out := os.Stderr
|
||||
if Server.LogFile != "" {
|
||||
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)
|
||||
if err != nil {
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
@@ -475,7 +444,6 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
@@ -667,7 +635,7 @@ func validateScanSchedule() error {
|
||||
}
|
||||
|
||||
func validateBackupSchedule() error {
|
||||
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
||||
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
||||
Server.Backup.Schedule = ""
|
||||
return nil
|
||||
}
|
||||
@@ -764,6 +732,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiwelcomemessage", "")
|
||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||
viper.SetDefault("enabletranscodingconfig", false)
|
||||
viper.SetDefault("enabletranscodingcancellation", false)
|
||||
viper.SetDefault("transcodingcachesize", "100MB")
|
||||
viper.SetDefault("imagecachesize", "100MB")
|
||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||
@@ -795,7 +764,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
viper.SetDefault("artistimagefolder", "")
|
||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
viper.SetDefault("enablegravatar", false)
|
||||
viper.SetDefault("enablefavourites", true)
|
||||
viper.SetDefault("enablestarrating", true)
|
||||
@@ -811,13 +780,12 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
viper.SetDefault("enablesharing", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
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)
|
||||
@@ -838,11 +806,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
|
||||
viper.SetDefault("scanner.scanonstartup", true)
|
||||
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
||||
viper.SetDefault("scanner.artistsplitexceptions", []string{})
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.ignoredotfolders", true)
|
||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||
@@ -851,9 +817,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("transcoding.maxconcurrent", 0)
|
||||
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
|
||||
viper.SetDefault("transcoding.enablecancellation", false)
|
||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
@@ -866,8 +829,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
||||
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("jellyfin.enabled", false)
|
||||
viper.SetDefault("jellyfin.servername", "")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
@@ -897,15 +858,9 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
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.
|
||||
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartworkthrottlebuffered", true)
|
||||
viper.SetDefault("artworkworkerconcurrency", 4)
|
||||
viper.SetDefault("artworkexternalmaxrps", 2)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devexternalscanner", true)
|
||||
@@ -916,6 +871,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devenablepluginsinsights", true)
|
||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devoptimizedb", true)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
}
|
||||
@@ -972,14 +928,3 @@ func getConfigFile(cfgFile string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
|
||||
// Subsonic, Jellyfin, native API, UI).
|
||||
//
|
||||
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
|
||||
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
|
||||
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
|
||||
// count is only loosely related to core count, and the floor is what matters on small machines.
|
||||
func MaxOpenConns() int {
|
||||
return max(4, runtime.NumCPU())
|
||||
}
|
||||
+16
-14
@@ -58,19 +58,6 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scheduled DB analysis", func() {
|
||||
It("is enabled by default", func() {
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
|
||||
})
|
||||
|
||||
It("can be disabled", func() {
|
||||
viper.Set("enablescheduleddbanalyze", false)
|
||||
conf.Load(true)
|
||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateURL", func() {
|
||||
It("accepts a valid http URL", func() {
|
||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
||||
@@ -199,12 +186,27 @@ var _ = Describe("Configuration", func() {
|
||||
}).To(PanicWith(ContainSubstring("Error reading config file")))
|
||||
})
|
||||
|
||||
It("is called when DataFolder is not writable", func() {
|
||||
viper.SetDefault("datafolder", invalidPath)
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error creating data path")))
|
||||
})
|
||||
|
||||
It("is called when CacheFolder is not writable", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("cachefolder", invalidPath)
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error creating cache path")))
|
||||
})
|
||||
|
||||
It("is called when LogFile path is not writable", func() {
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
|
||||
Expect(func() {
|
||||
conf.Load(true)
|
||||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
||||
}).To(PanicWith(ContainSubstring("Error opening log file")))
|
||||
})
|
||||
|
||||
It("is called when BaseURL is invalid", func() {
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Dir wraps a directory path and creates the directory on demand. Dir is a
|
||||
// plain value type — safe to copy, compare, and print via reflection-based
|
||||
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
|
||||
// Directory creation is delegated to os.MkdirAll on every Path() call;
|
||||
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
|
||||
// directory already exists.
|
||||
type Dir struct {
|
||||
path string
|
||||
perm os.FileMode
|
||||
}
|
||||
|
||||
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
|
||||
func NewDir(path string) Dir {
|
||||
return Dir{path: path, perm: os.ModePerm}
|
||||
}
|
||||
|
||||
// NewDirWithPerm creates a new Dir with the given path and permissions.
|
||||
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
|
||||
// directory-creation time; pass an explicit non-zero mode to constrain the
|
||||
// permissions.
|
||||
func NewDirWithPerm(path string, perm os.FileMode) Dir {
|
||||
return Dir{path: path, perm: perm}
|
||||
}
|
||||
|
||||
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
|
||||
func (d Dir) String() string {
|
||||
return d.path
|
||||
}
|
||||
|
||||
// Path ensures the directory exists and returns its path. Safe to call
|
||||
// repeatedly; an empty path is returned as-is with no error.
|
||||
func (d Dir) Path() (string, error) {
|
||||
if d.path == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
|
||||
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
|
||||
}
|
||||
return d.path, nil
|
||||
}
|
||||
|
||||
// MustPath calls Path() and calls logFatal on error.
|
||||
func (d Dir) MustPath() string {
|
||||
path, err := d.Path()
|
||||
if err != nil {
|
||||
logFatal("creating directory:", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
|
||||
// prints the path string instead of the internal struct fields.
|
||||
func (d Dir) GoString() string {
|
||||
return fmt.Sprintf("%q", d.path)
|
||||
}
|
||||
|
||||
// MarshalText returns the raw path bytes. No side effects.
|
||||
func (d Dir) MarshalText() ([]byte, error) {
|
||||
return []byte(d.path), nil
|
||||
}
|
||||
|
||||
// UnmarshalText sets the path from bytes. No side effects.
|
||||
func (d *Dir) UnmarshalText(text []byte) error {
|
||||
d.path = string(text)
|
||||
if d.perm == 0 {
|
||||
d.perm = os.ModePerm
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package conf_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/kr/pretty"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Dir", func() {
|
||||
Describe("NewDir", func() {
|
||||
It("creates a Dir with the given path without side effects", func() {
|
||||
d := conf.NewDir("/some/path")
|
||||
Expect(d.String()).To(Equal("/some/path"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("String", func() {
|
||||
It("returns the raw path without creating the directory", func() {
|
||||
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
|
||||
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Path", func() {
|
||||
It("creates the directory and returns the path on first call", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/subdir/nested"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path, err := d.Path()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(target))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("is idempotent on subsequent calls", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/idempotent"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path1, err1 := d.Path()
|
||||
path2, err2 := d.Path()
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(path1).To(Equal(path2))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("returns an error when directory cannot be created", func() {
|
||||
f := GinkgoT().TempDir()
|
||||
blocker := f + "/blocker"
|
||||
By("creating a file that blocks directory creation")
|
||||
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
|
||||
invalid := blocker + "/subdir"
|
||||
|
||||
d := conf.NewDir(invalid)
|
||||
_, pathErr := d.Path()
|
||||
Expect(pathErr).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns empty path and no error for empty path", func() {
|
||||
d := conf.NewDir("")
|
||||
path, err := d.Path()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MustPath", func() {
|
||||
It("returns the path when directory is created successfully", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
target := dir + "/mustpath"
|
||||
d := conf.NewDir(target)
|
||||
|
||||
path := d.MustPath()
|
||||
Expect(path).To(Equal(target))
|
||||
Expect(target).To(BeADirectory())
|
||||
})
|
||||
|
||||
It("calls logFatal on error", func() {
|
||||
var fatalMsg []any
|
||||
restore := conf.SetLogFatal(func(args ...any) {
|
||||
fatalMsg = args
|
||||
panic("logFatal called")
|
||||
})
|
||||
DeferCleanup(restore)
|
||||
|
||||
f := GinkgoT().TempDir() + "/blocker"
|
||||
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
|
||||
invalid := f + "/subdir"
|
||||
|
||||
d := conf.NewDir(invalid)
|
||||
Expect(func() { d.MustPath() }).To(Panic())
|
||||
Expect(fatalMsg).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MarshalText", func() {
|
||||
It("returns the raw path bytes without side effects", func() {
|
||||
d := conf.NewDir("/marshal/path")
|
||||
b, err := d.MarshalText()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(b)).To(Equal("/marshal/path"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UnmarshalText", func() {
|
||||
It("sets the path from bytes without side effects", func() {
|
||||
d := conf.NewDir("")
|
||||
err := d.UnmarshalText([]byte("/unmarshal/path"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(d.String()).To(Equal("/unmarshal/path"))
|
||||
})
|
||||
|
||||
It("allows round-trip marshal/unmarshal", func() {
|
||||
d1 := conf.NewDir("/round/trip")
|
||||
b, err := d1.MarshalText()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var d2 conf.Dir
|
||||
err = d2.UnmarshalText(b)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(d2.String()).To(Equal(d1.String()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GoString", func() {
|
||||
// Regression: pretty.Sprintf("%# v", ...) is used by the
|
||||
// configuration dump. It must render Dir as a quoted path via
|
||||
// GoString, not dump the internal struct fields.
|
||||
It("renders Dir as a quoted path under pretty.Sprintf", func() {
|
||||
type host struct {
|
||||
DataFolder conf.Dir
|
||||
}
|
||||
h := host{DataFolder: conf.NewDir("./data")}
|
||||
out := pretty.Sprintf("%# v", h)
|
||||
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
|
||||
Expect(out).ToNot(ContainSubstring("perm:"))
|
||||
Expect(out).ToNot(ContainSubstring("path:"))
|
||||
})
|
||||
|
||||
It("is safe to copy and use concurrently", func() {
|
||||
// Regression for the Windows "sync: unlock of unlocked mutex"
|
||||
// crash that was caused by copying a Dir embedding sync.Once.
|
||||
// Dir is a plain value type now, but keep the concurrent stress
|
||||
// test to lock in the property.
|
||||
dir := GinkgoT().TempDir()
|
||||
d := conf.NewDir(dir + "/race")
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Go(func() {
|
||||
copy1 := d
|
||||
_ = pretty.Sprintf("%# v", copy1)
|
||||
_, _ = copy1.Path()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
})
|
||||
})
|
||||
+5
-22
@@ -14,16 +14,9 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
|
||||
// PlaylistsImportPendingFlagKey marks that playlist import was deferred because
|
||||
// no admin user existed yet; the next scan with an admin imports them.
|
||||
PlaylistsImportPendingFlagKey = "PlaylistsImportPending"
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
LastDBAnalyzeAtKey = "LastDBAnalyzeAt"
|
||||
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
|
||||
DBAnalyzePendingKey = "DBAnalyzePending"
|
||||
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
@@ -32,12 +25,7 @@ const (
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
|
||||
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
ArtworkPostBackfillPruneDelay = 10 * time.Minute
|
||||
OptimizeDBSchedule = "@every 24h"
|
||||
|
||||
// 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
|
||||
@@ -53,11 +41,6 @@ const (
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
|
||||
// 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.
|
||||
JellyfinServerIDKey = "JellyfinServerID"
|
||||
|
||||
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
||||
// available at https://unsplash.com/collections/20072696/navidrome
|
||||
@@ -170,25 +153,25 @@ var (
|
||||
Name: "mp3 audio",
|
||||
TargetFormat: "mp3",
|
||||
DefaultBitRate: 192,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
|
||||
},
|
||||
{
|
||||
Name: "opus audio",
|
||||
TargetFormat: "opus",
|
||||
DefaultBitRate: 128,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
|
||||
},
|
||||
{
|
||||
Name: "aac audio",
|
||||
TargetFormat: "aac",
|
||||
DefaultBitRate: 256,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||
},
|
||||
{
|
||||
Name: "flac audio",
|
||||
TargetFormat: "flac",
|
||||
DefaultBitRate: 0,
|
||||
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -",
|
||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
@@ -34,22 +33,15 @@ type ExternalImage struct {
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ISRC string
|
||||
Artists []Artist
|
||||
Album string
|
||||
AlbumMBID string
|
||||
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||
}
|
||||
|
||||
// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes
|
||||
// rather than comparing with ==, which the Artists slice makes illegal.
|
||||
func (s Song) Equals(other Song) bool {
|
||||
h1, _ := hashstructure.Hash(s, nil)
|
||||
h2, _ := hashstructure.Hash(other, nil)
|
||||
return h1 == h2
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ISRC string
|
||||
Artist string
|
||||
ArtistMBID string
|
||||
Album string
|
||||
AlbumMBID string
|
||||
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
+19
-37
@@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -61,15 +60,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
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
|
||||
// tracks were already written; the rejected one is not
|
||||
// present in the archive (addFileToZip aborts before
|
||||
// writing its entry header).
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
}
|
||||
}
|
||||
err = z.Close()
|
||||
@@ -129,12 +120,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||
// Abort the whole archive: continuing would silently emit
|
||||
// empty zip entries since the headers are already written.
|
||||
_ = z.Close()
|
||||
return addErr
|
||||
}
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
@@ -176,27 +162,6 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
|
||||
// Open the source before writing the zip entry header so a rejection
|
||||
// (limiter, missing file, etc.) does not leave an empty entry in the
|
||||
// archive.
|
||||
var r io.ReadCloser
|
||||
var err error
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: filename,
|
||||
Modified: mf.UpdatedAt,
|
||||
@@ -207,6 +172,23 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||
return err
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", path, err)
|
||||
|
||||
@@ -89,32 +89,6 @@ var _ = Describe("Archiver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the transcode limiter rejects a file", func() {
|
||||
It("aborts the archive instead of continuing with empty entries", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
|
||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(MatchError(stream.ErrTooManyTranscodes))
|
||||
// NewStream should only have been called once: the loop must bail
|
||||
// out on the rejection instead of trying every remaining track.
|
||||
ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1)
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
|
||||
@@ -15,24 +15,9 @@ import (
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestArtwork(t *testing.T) {
|
||||
// Runs unconditionally: the two leaks below are pre-existing and out of this
|
||||
// package's control, so they're ignored by exact top-function instead.
|
||||
defer goleak.VerifyNone(t,
|
||||
goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"),
|
||||
// notify's own init() starts a singleton tree the moment it's imported (via
|
||||
// core/storage/local or plugins); recursive on darwin, nonrecursive on linux.
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"),
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"),
|
||||
goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"),
|
||||
// The old cache_warmer.go starts a goroutine per NewCacheWarmer call with
|
||||
// no shutdown path (dark-launch target for Phase 2, not touched here).
|
||||
goleak.IgnoreTopFunction("github.com/navidrome/navidrome/core/artwork.(*cacheWarmer).waitSignal"),
|
||||
)
|
||||
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
|
||||
@@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID
|
||||
|
||||
// Configure cache
|
||||
conf.Server.ImageCacheSize = cacheSize
|
||||
conf.Server.CacheFolder = conf.NewDir(tmpDir)
|
||||
conf.Server.CacheFolder = tmpDir
|
||||
conf.Server.CoverArtQuality = 75
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
|
||||
@@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for range n {
|
||||
for g := 0; g < n; g++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
||||
|
||||
@@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte {
|
||||
// generateGradientImage creates an RGBA image with a diagonal gradient pattern.
|
||||
func generateGradientImage(width, height int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
r := uint8((x * 255) / width)
|
||||
g := uint8((y * 255) / height)
|
||||
b := uint8(((x + y) * 255) / (width + height))
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash),
|
||||
// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes.
|
||||
package blurhash
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference.
|
||||
const maxInputSize = 128
|
||||
|
||||
// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula).
|
||||
func Components(width, height int) (int, int) {
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
xf := math.Sqrt(16.0 * float64(width) / float64(height))
|
||||
yf := xf * float64(height) / float64(width)
|
||||
return min(int(xf)+1, 9), min(int(yf)+1, 9)
|
||||
}
|
||||
|
||||
// Encode returns the blurhash of img using xComp x yComp components.
|
||||
func Encode(img image.Image, xComp, yComp int) (string, error) {
|
||||
if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 {
|
||||
return "", errors.New("blurhash: components must be between 1 and 9")
|
||||
}
|
||||
rgba := toRGBA(downscale(img))
|
||||
bounds := rgba.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return "", errors.New("blurhash: empty image")
|
||||
}
|
||||
|
||||
cosX := make([][]float64, xComp)
|
||||
for i := range cosX {
|
||||
cosX[i] = make([]float64, w)
|
||||
for x := range cosX[i] {
|
||||
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
|
||||
}
|
||||
}
|
||||
cosY := make([][]float64, yComp)
|
||||
for j := range cosY {
|
||||
cosY[j] = make([]float64, h)
|
||||
for y := range cosY[j] {
|
||||
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
|
||||
}
|
||||
}
|
||||
|
||||
lin := srgbToLinearTable()
|
||||
factors := make([][3]float64, xComp*yComp)
|
||||
for y := 0; y < h; y++ {
|
||||
row := rgba.Pix[y*rgba.Stride:]
|
||||
for x := 0; x < w; x++ {
|
||||
p := x * 4
|
||||
lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]]
|
||||
for j := 0; j < yComp; j++ {
|
||||
for i := 0; i < xComp; i++ {
|
||||
basis := cosX[i][x] * cosY[j][y]
|
||||
f := &factors[j*xComp+i]
|
||||
f[0] += basis * lr
|
||||
f[1] += basis * lg
|
||||
f[2] += basis * lb
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx := range factors {
|
||||
norm := 2.0
|
||||
if idx == 0 {
|
||||
norm = 1.0
|
||||
}
|
||||
scale := norm / float64(w*h)
|
||||
factors[idx][0] *= scale
|
||||
factors[idx][1] *= scale
|
||||
factors[idx][2] *= scale
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(Encode83((xComp-1)+(yComp-1)*9, 1))
|
||||
|
||||
ac := factors[1:]
|
||||
maxVal := 1.0
|
||||
if len(ac) > 0 {
|
||||
actualMax := 0.0
|
||||
for _, f := range ac {
|
||||
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
|
||||
}
|
||||
quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5))))
|
||||
maxVal = float64(quantMax+1) / 166
|
||||
sb.WriteString(Encode83(quantMax, 1))
|
||||
} else {
|
||||
sb.WriteString(Encode83(0, 1))
|
||||
}
|
||||
|
||||
dc := factors[0]
|
||||
sb.WriteString(Encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
|
||||
for _, f := range ac {
|
||||
sb.WriteString(Encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the
|
||||
// image.At interface (~16k allocs per encode).
|
||||
func toRGBA(img image.Image) *image.RGBA {
|
||||
if rgba, ok := img.(*image.RGBA); ok {
|
||||
return rgba
|
||||
}
|
||||
b := img.Bounds()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
|
||||
var t [256]float64
|
||||
for i := range t {
|
||||
t[i] = srgbToLinear(i)
|
||||
}
|
||||
return &t
|
||||
})
|
||||
|
||||
func downscale(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxInputSize && h <= maxInputSize {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxInputSize) / float64(max(w, h))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
|
||||
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func quantAC(v, maxVal float64) int {
|
||||
return int(math.Max(0, math.Min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
|
||||
}
|
||||
|
||||
func signPow(v, exp float64) float64 {
|
||||
return math.Copysign(math.Pow(math.Abs(v), exp), v)
|
||||
}
|
||||
|
||||
func srgbToLinear(v int) float64 {
|
||||
f := float64(v) / 255
|
||||
if f <= 0.04045 {
|
||||
return f / 12.92
|
||||
}
|
||||
return math.Pow((f+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
func linearToSRGB(v float64) int {
|
||||
v = math.Min(math.Max(0, v), 1)
|
||||
if v <= 0.0031308 {
|
||||
return int(v*12.92*255 + 0.5)
|
||||
}
|
||||
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
|
||||
}
|
||||
|
||||
// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length, using the
|
||||
// blurhash spec's alphabet.
|
||||
func Encode83(value, length int) string {
|
||||
b := make([]byte, length)
|
||||
for i := length - 1; i >= 0; i-- {
|
||||
b[i] = alphabet[value%83]
|
||||
value /= 83
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package blurhash_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
)
|
||||
|
||||
// benchImage builds a deterministic gradient so runs are comparable across revisions.
|
||||
func benchImage(size int) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{
|
||||
R: uint8(255 * x / size),
|
||||
G: uint8(255 * y / size),
|
||||
B: uint8((x + y) * 255 / (2 * size)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func BenchmarkEncode(b *testing.B) {
|
||||
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
|
||||
img := benchImage(size)
|
||||
x, y := blurhash.Components(size, size)
|
||||
b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
if _, err := blurhash.Encode(img, x, y); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package blurhash_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestBlurHash(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "BlurHash Suite")
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package blurhash_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
|
||||
func decode83(s string) int {
|
||||
v := 0
|
||||
for _, c := range s {
|
||||
v = v*83 + strings.IndexRune(alphabet, c)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func solidImage(w, h int, c color.NRGBA) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetNRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func gradientImage(w, h int) image.Image {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
var _ = Describe("Components", func() {
|
||||
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
|
||||
func(w, h, expectedX, expectedY int) {
|
||||
x, y := blurhash.Components(w, h)
|
||||
Expect(x).To(Equal(expectedX))
|
||||
Expect(y).To(Equal(expectedY))
|
||||
},
|
||||
Entry("square album art", 600, 600, 5, 5),
|
||||
Entry("small square", 1, 1, 5, 5),
|
||||
Entry("landscape 16:9", 1920, 1080, 6, 4),
|
||||
Entry("portrait 9:16", 1080, 1920, 4, 6),
|
||||
Entry("extreme landscape capped at 9", 10000, 100, 9, 1),
|
||||
Entry("zero width", 0, 600, 0, 0),
|
||||
Entry("zero height", 600, 0, 0, 0),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Encode", func() {
|
||||
It("rejects out-of-range components", func() {
|
||||
_, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5)
|
||||
Expect(err).To(HaveOccurred())
|
||||
_, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("produces the spec-mandated length", func() {
|
||||
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component
|
||||
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1)))
|
||||
})
|
||||
|
||||
It("encodes the size flag as the first character", func() {
|
||||
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9))
|
||||
})
|
||||
|
||||
It("stores the average color in the DC component", func() {
|
||||
h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}), 4, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
dc := decode83(h[2:6])
|
||||
Expect(dc >> 16).To(BeNumerically("~", 200, 1))
|
||||
Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1))
|
||||
Expect(dc & 0xFF).To(BeNumerically("~", 50, 1))
|
||||
})
|
||||
|
||||
It("is deterministic", func() {
|
||||
img := gradientImage(64, 64)
|
||||
h1, err1 := blurhash.Encode(img, 5, 5)
|
||||
h2, err2 := blurhash.Encode(img, 5, 5)
|
||||
Expect(err1).ToNot(HaveOccurred())
|
||||
Expect(err2).ToNot(HaveOccurred())
|
||||
Expect(h1).To(Equal(h2))
|
||||
})
|
||||
|
||||
It("produces different hashes for different images", func() {
|
||||
h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}), 4, 4)
|
||||
h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4)
|
||||
Expect(h1).ToNot(Equal(h2))
|
||||
})
|
||||
|
||||
It("downscales large images internally without changing the result materially", func() {
|
||||
// A 1000px solid image must encode fine and carry the same DC as its small version.
|
||||
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(big[2:6]).To(Equal(small[2:6]))
|
||||
})
|
||||
})
|
||||
@@ -357,98 +357,6 @@ var _ = Describe("Album artwork resolution", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder
|
||||
// fallback can pick up images from the ARTIST folder, serving the artist
|
||||
// thumbnail as album art for any album without its own image files.
|
||||
When("an album has no images and the artist folder has folder.jpg", func() {
|
||||
// Artist/
|
||||
// ├── folder.jpg ← artist thumbnail, must NOT become album art
|
||||
// ├── Album A/
|
||||
// │ └── 01 - Track.mp3 (no images)
|
||||
// └── Album B/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("does not use the artist image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"Album A has no images of its own, so it must fall through to the placeholder "+
|
||||
"instead of inheriting the artist folder's folder.jpg")
|
||||
|
||||
alB := albumByName("Album B")
|
||||
Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a single-disc album is spread across sibling folders under the artist folder", func() {
|
||||
// Artist/
|
||||
// ├── folder.jpg ← artist thumbnail, must NOT become album art
|
||||
// ├── Album A/
|
||||
// │ └── 01 - Track.mp3 (album: "Album A")
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder)
|
||||
// └── Album B/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("does not use the artist image as album art for the spread album", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": imageFile("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": imageFile("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
_, err := readArtworkOrErr(alA.CoverArtID())
|
||||
Expect(err).To(HaveOccurred(),
|
||||
"the spread album has no images of its own, so it must fall through to the "+
|
||||
"placeholder instead of inheriting the artist folder's folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() {
|
||||
// Artist/
|
||||
// ├── cover.jpg ← artist image; matches cover.* (first pattern),
|
||||
// │ must NOT shadow the album's own front.jpg
|
||||
// ├── Album A/
|
||||
// │ ├── 01 - Track.mp3 (album: "Album A")
|
||||
// │ └── front.jpg ← should win
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A")
|
||||
// └── Album B/
|
||||
// └── 01 - Track.mp3
|
||||
It("prefers the album's own art over the artist image", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": imageFile("artist-image"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A/front.jpg": imageFile("album-a-front"),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: scanner should treat the two sibling folders as one spread album")
|
||||
Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front")))
|
||||
})
|
||||
})
|
||||
|
||||
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
|
||||
@@ -177,9 +177,6 @@ func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles,
|
||||
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) ArtistImageResult(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package artworke2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -64,7 +63,7 @@ func setupHarness() {
|
||||
// Reuse the suite-level DB path so the singleton connection keeps working
|
||||
// across specs (see suiteDBTempDir comment).
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.DataFolder = tempDir
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
|
||||
@@ -105,16 +104,3 @@ func firstAlbum() model.Album {
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// FingerprintPropertyKey is the model.PropertyRepository key Backfill compares against
|
||||
// to detect artwork-affecting config changes across restarts.
|
||||
const FingerprintPropertyKey = "artwork.fingerprint"
|
||||
|
||||
// staleAbsentAge is how old an absent resolution must be before the recheck job retries it.
|
||||
const staleAbsentAge = 24 * time.Hour
|
||||
|
||||
// staleAbsentKinds are the item kinds eligible for the periodic stale-absent recheck.
|
||||
var staleAbsentKinds = []string{"ar", "al", "pl", "ra"}
|
||||
|
||||
// Fingerprint summarizes the config knobs that affect artwork resolution outcomes; a
|
||||
// change means previously resolved (or absent) state may no longer be correct.
|
||||
func Fingerprint() string {
|
||||
raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%s",
|
||||
conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder,
|
||||
conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, consts.Version)
|
||||
sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Backfill enqueues artwork resolution for every entity when the config fingerprint changed
|
||||
// (or was never stored), artists first so those pages resolve before the larger backlog.
|
||||
func Backfill(ctx context.Context, ds model.DataStore) (bool, error) {
|
||||
ctx = auth.WithAdminUser(ctx, ds)
|
||||
current := Fingerprint()
|
||||
props := ds.Property(ctx)
|
||||
stored, err := props.DefaultGet(FingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if stored == current {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Artists first: few entities, most external-dependent, so they get queue headstart.
|
||||
kinds := []struct {
|
||||
kind string
|
||||
fetch func() ([]string, error)
|
||||
}{
|
||||
{"ar", func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }},
|
||||
{"al", func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }},
|
||||
{"pl", func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
|
||||
{"ra", func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
|
||||
}
|
||||
for _, k := range kinds {
|
||||
ids, err := k.fetch()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := props.Put(FingerprintPropertyKey, current); err != nil {
|
||||
return false, err
|
||||
}
|
||||
log.Info(ctx, "Artwork: config fingerprint changed, backfill enqueued")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]model.ArtworkQueueItem, len(ids))
|
||||
for i, id := range ids {
|
||||
items[i] = model.ArtworkQueueItem{
|
||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill,
|
||||
}
|
||||
}
|
||||
return ds.ArtworkQueue(ctx).Enqueue(items...)
|
||||
}
|
||||
|
||||
// EnqueueStaleAbsentAll requeues absent-state entries older than staleAbsentAge, across
|
||||
// every artwork-bearing kind, for the periodic recheck job.
|
||||
func EnqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
||||
cutoff := time.Now().Add(-staleAbsentAge)
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range staleAbsentKinds {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only
|
||||
// visible when the ctx carries an admin, so headless work must wrap ctx with one first.
|
||||
type visibilityPlaylistDS struct {
|
||||
*tests.MockDataStore
|
||||
private model.Playlist
|
||||
tracks model.PlaylistTrackRepository
|
||||
}
|
||||
|
||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
repo := tests.CreateMockPlaylistRepo()
|
||||
repo.TracksRepo = v.tracks
|
||||
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
||||
repo.SetData(model.Playlists{v.private})
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
func adminUserRepo() *tests.MockedUserRepo {
|
||||
repo := tests.CreateMockUserRepo()
|
||||
Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||
return repo
|
||||
}
|
||||
|
||||
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
|
||||
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
|
||||
type orderTrackingQueueRepo struct {
|
||||
*tests.MockArtworkQueueRepo
|
||||
callKinds []string
|
||||
}
|
||||
|
||||
func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
|
||||
if len(items) > 0 {
|
||||
o.callKinds = append(o.callKinds, items[0].ItemKind)
|
||||
}
|
||||
return o.MockArtworkQueueRepo.Enqueue(items...)
|
||||
}
|
||||
|
||||
var _ = Describe("Housekeeping", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
queueRepo *orderTrackingQueueRepo
|
||||
propRepo *tests.MockedPropertyRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
conf.Server.CoverArtPriority = "embedded, folder"
|
||||
conf.Server.ArtistArtPriority = "artist.jpg"
|
||||
conf.Server.Agents = "spotify"
|
||||
conf.Server.EnableExternalServices = true
|
||||
|
||||
queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()}
|
||||
propRepo = &tests.MockedPropertyRepo{}
|
||||
ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo}
|
||||
})
|
||||
|
||||
seedEntities := func() {
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{{ID: "al1"}})
|
||||
ds.MockedAlbum = albumRepo
|
||||
|
||||
playlistRepo := tests.CreateMockPlaylistRepo()
|
||||
playlistRepo.SetData(model.Playlists{{ID: "pl1"}})
|
||||
ds.MockedPlaylist = playlistRepo
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.All = model.Radios{{ID: "ra1"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
}
|
||||
|
||||
Describe("Fingerprint", func() {
|
||||
It("changes when a fingerprint-affecting config value changes", func() {
|
||||
f1 := Fingerprint()
|
||||
conf.Server.CoverArtPriority = "folder, embedded"
|
||||
f2 := Fingerprint()
|
||||
Expect(f1).NotTo(Equal(f2))
|
||||
})
|
||||
|
||||
It("changes when ArtistImageFolder changes", func() {
|
||||
conf.Server.ArtistImageFolder = "/before"
|
||||
f1 := Fingerprint()
|
||||
conf.Server.ArtistImageFolder = "/after"
|
||||
Expect(Fingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
|
||||
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
f1 := Fingerprint()
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
Expect(Fingerprint()).NotTo(Equal(f1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Backfill", func() {
|
||||
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(FingerprintPropertyKey, Fingerprint())).To(Succeed())
|
||||
|
||||
did, err := Backfill(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeFalse())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
})
|
||||
|
||||
It("runs the backfill when no fingerprint was ever stored", func() {
|
||||
seedEntities()
|
||||
|
||||
did, err := Backfill(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio
|
||||
|
||||
stored, err := propRepo.Get(FingerprintPropertyKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored).To(Equal(Fingerprint()))
|
||||
})
|
||||
|
||||
It("enqueues a private playlist by resolving it under an admin context", func() {
|
||||
ds.MockedUser = adminUserRepo()
|
||||
vds := &visibilityPlaylistDS{
|
||||
MockDataStore: ds,
|
||||
private: model.Playlist{ID: "plPrivate", OwnerID: "admin"},
|
||||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
|
||||
did, err := Backfill(ctx, vds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(FingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
did, err := Backfill(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
|
||||
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
||||
artistCallIdx := -1
|
||||
for i, k := range queueRepo.callKinds {
|
||||
if k == "ar" {
|
||||
artistCallIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(artistCallIdx).To(Equal(0), "artists must be the first Enqueue call")
|
||||
for i, k := range queueRepo.callKinds {
|
||||
if k != "ar" {
|
||||
Expect(i).To(BeNumerically(">", artistCallIdx))
|
||||
}
|
||||
}
|
||||
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill))
|
||||
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueStaleAbsentAll", func() {
|
||||
var artRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds.MockedArtwork = artRepo
|
||||
queueRepo.ItemArtworkSource = artRepo
|
||||
})
|
||||
|
||||
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
recent := time.Now().Add(-time.Hour)
|
||||
|
||||
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
// Not stale: too recent.
|
||||
artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent}
|
||||
// Not absent: has a resolved hash.
|
||||
artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old}
|
||||
|
||||
err := EnqueueStaleAbsentAll(ctx, ds)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(queueRepo.Data).To(HaveLen(4))
|
||||
for _, it := range queueRepo.Data {
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
}
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
func HashImage(r io.Reader) (string, error) {
|
||||
d := xxh3.New()
|
||||
if _, err := io.Copy(d, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%016x", d.Sum64()), nil
|
||||
}
|
||||
|
||||
// ImageStore is the content-addressed store for artwork images that have no
|
||||
// library file backing them (external downloads, embedded extractions, generated).
|
||||
type ImageStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewImageStore(rootDir string) *ImageStore {
|
||||
return &ImageStore{root: rootDir}
|
||||
}
|
||||
|
||||
// ProvideImageStore roots the store in its own subtree under the data folder, so
|
||||
// Prune's recursive sweep never reaches the per-entity upload folders next to it.
|
||||
func ProvideImageStore() *ImageStore {
|
||||
return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, "store"))
|
||||
}
|
||||
|
||||
// extForMime is deliberately NOT mime.ExtensionsByType: extensions are baked into
|
||||
// content-addressed paths and re-derived on Open, so they must be stable across OSes.
|
||||
func extForMime(m string) string {
|
||||
switch m {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
}
|
||||
return ".img"
|
||||
}
|
||||
|
||||
// validHash rejects anything but 16 lowercase hex chars: known-absent states carry "",
|
||||
// and malformed persisted hashes must never reach path sharding (slice panics, separators).
|
||||
func validHash(hash string) bool {
|
||||
if len(hash) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, c := range []byte(hash) {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *ImageStore) path(hash, mimeType string) string {
|
||||
return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType))
|
||||
}
|
||||
|
||||
func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
|
||||
if !validHash(hash) {
|
||||
return fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
dst := s.path(hash, mimeType)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
// A touched mtime marks the file live so a concurrent prune spares it.
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(dst, now, now); err == nil {
|
||||
return nil
|
||||
}
|
||||
// touch failed (file likely pruned concurrently) — fall through and write it
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) {
|
||||
if !validHash(hash) {
|
||||
return nil, fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
return os.Open(s.path(hash, mimeType))
|
||||
}
|
||||
|
||||
// Remove deletes the store file unless it is newer than olderThan, in which case
|
||||
// an overlapping acquisition may have just touched it and be about to commit its row.
|
||||
func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error {
|
||||
if !validHash(hash) {
|
||||
return fmt.Errorf("imagestore: invalid hash %q", hash)
|
||||
}
|
||||
path := s.path(hash, mimeType)
|
||||
info, err := os.Stat(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.ModTime().After(olderThan) {
|
||||
return nil
|
||||
}
|
||||
err = os.Remove(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Sweep removes store files not accepted by keep. Files modified after cutoff
|
||||
// (including temp files) are always kept: their acquisition row may not be committed yet.
|
||||
func (s *ImageStore) Sweep(cutoff time.Time, keep func(hash, ext string) bool) (int, error) {
|
||||
removed := 0
|
||||
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.ModTime().After(cutoff) {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window
|
||||
if !remove {
|
||||
ext := filepath.Ext(name)
|
||||
remove = !keep(strings.TrimSuffix(name, ext), ext)
|
||||
}
|
||||
if remove {
|
||||
// #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return removed, nil
|
||||
}
|
||||
return removed, err
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ImageStore", func() {
|
||||
var store *ImageStore
|
||||
var root string
|
||||
|
||||
BeforeEach(func() {
|
||||
root = GinkgoT().TempDir()
|
||||
store = NewImageStore(root)
|
||||
})
|
||||
|
||||
It("hashes deterministically", func() {
|
||||
h1, err := HashImage(bytes.NewReader([]byte("some image bytes")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
h2, _ := HashImage(bytes.NewReader([]byte("some image bytes")))
|
||||
Expect(h1).To(Equal(h2))
|
||||
Expect(h1).To(HaveLen(16))
|
||||
h3, _ := HashImage(bytes.NewReader([]byte("other bytes")))
|
||||
Expect(h3).ToNot(Equal(h1))
|
||||
})
|
||||
|
||||
It("writes sharded and reads back", func() {
|
||||
data := []byte("jpeg-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
got, _ := io.ReadAll(rc)
|
||||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("is idempotent on duplicate writes and preserves the original content", func() {
|
||||
data := []byte("dup")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
// A duplicate write only touches mtime; passing different bytes under the same
|
||||
// hash proves the second reader is never consumed to overwrite the file.
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer rc.Close()
|
||||
got, err := io.ReadAll(rc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(data))
|
||||
})
|
||||
|
||||
It("refreshes the mtime on a duplicate write", func() {
|
||||
data := []byte("touch-me")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
info, err := os.Stat(store.path(h, "image/png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute)))
|
||||
})
|
||||
|
||||
It("rewrites the bytes when the existing file vanished before the liveness touch", func() {
|
||||
data := []byte("vanishing")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
for range 10 {
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(os.Remove(store.path(h, "image/png"))).To(Succeed())
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
got, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
Expect(got).To(Equal(data))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns fs.ErrNotExist for missing images", func() {
|
||||
_, err := store.Open("beefbeefbeefbeef", "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("removes without error when already gone", func() {
|
||||
Expect(store.Remove("beefbeefbeefbeef", "image/jpeg", time.Now())).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects invalid hashes instead of panicking", func() {
|
||||
for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} {
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash")))
|
||||
_, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid hash")))
|
||||
Expect(store.Remove(h, "image/jpeg", time.Now())).To(MatchError(ContainSubstring("invalid hash")))
|
||||
}
|
||||
})
|
||||
|
||||
It("spares a file newer than the cutoff, removes an aged one", func() {
|
||||
fresh := []byte("fresh")
|
||||
hf, _ := HashImage(bytes.NewReader(fresh))
|
||||
Expect(store.Write(hf, "image/jpeg", bytes.NewReader(fresh))).To(Succeed())
|
||||
|
||||
aged := []byte("aged")
|
||||
ha, _ := HashImage(bytes.NewReader(aged))
|
||||
Expect(store.Write(ha, "image/jpeg", bytes.NewReader(aged))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(ha, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
Expect(store.Remove(hf, "image/jpeg", cutoff)).To(Succeed())
|
||||
Expect(store.Remove(ha, "image/jpeg", cutoff)).To(Succeed())
|
||||
|
||||
rc, err := store.Open(hf, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
_, err = store.Open(ha, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("sweeps unknown files, keeps known ones", func() {
|
||||
d1 := []byte("keep-me")
|
||||
h1, _ := HashImage(bytes.NewReader(d1))
|
||||
Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed())
|
||||
d2 := []byte("orphan")
|
||||
h2, _ := HashImage(bytes.NewReader(d2))
|
||||
Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed())
|
||||
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
_, err = store.Open(h2, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h1, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("sweeps a stale mime variant of a known hash, keeps the current one", func() {
|
||||
data := []byte("same-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
// The recorded mime is image/jpeg, so the .png variant is obsolete.
|
||||
removed, err := store.Sweep(time.Now().Add(-time.Hour), func(hash, ext string) bool {
|
||||
return hash == h && ext == ".jpg"
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
_, err = store.Open(h, "image/png")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("keeps young unknown files inside the grace window", func() {
|
||||
d := []byte("fresh-orphan")
|
||||
h, _ := HashImage(bytes.NewReader(d))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string, string) bool { return false })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(0))
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("removes abandoned temp files past the grace window, keeps fresh ones", func() {
|
||||
oldTmp := filepath.Join(root, ".old.tmp")
|
||||
Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(oldTmp, old, old)).To(Succeed())
|
||||
|
||||
freshTmp := filepath.Join(root, ".fresh.tmp")
|
||||
Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed())
|
||||
|
||||
removed, err := store.Sweep(time.Now().Add(-time.Hour), func(string, string) bool { return true })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(removed).To(Equal(1))
|
||||
Expect(oldTmp).ToNot(BeAnExistingFile())
|
||||
Expect(freshTmp).To(BeAnExistingFile())
|
||||
})
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// outcome tells the worker what to do with the queue row: found/absent
|
||||
// delete it, failed reschedules it via MarkFailed.
|
||||
type outcome int
|
||||
|
||||
const (
|
||||
outcomeFound outcome = iota
|
||||
// outcomeFoundStale: state was written and is served, but a higher-priority external
|
||||
// step failed, so the row must retry (via MarkFailed) to give that source another chance.
|
||||
outcomeFoundStale
|
||||
outcomeAbsent
|
||||
outcomeFailed
|
||||
)
|
||||
|
||||
// thumbnailSize is the max dimension fed to blurhash.
|
||||
const thumbnailSize = 128
|
||||
|
||||
// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could
|
||||
// point at an arbitrarily large endpoint, and 20MB is generous for any real cover.
|
||||
const maxImageBytes = 20 << 20
|
||||
|
||||
// maxImagePixels caps declared dimensions: a tiny compressed file can declare a
|
||||
// huge canvas that image.Decode would expand into gigabytes (decompression bomb).
|
||||
const maxImagePixels = 64 << 20
|
||||
|
||||
// workerDeps are the collaborators processItem needs; extGate is set by NewWorker in
|
||||
// production and nil only in tests, where resolveItem falls back to a plain passthrough.
|
||||
type workerDeps struct {
|
||||
ds model.DataStore
|
||||
store *ImageStore
|
||||
prov external.Provider
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
extGate extGateFunc
|
||||
}
|
||||
|
||||
// processItem resolves one queue item end to end: find an image, hash/decode/
|
||||
// blurhash it, place its bytes, and persist the resulting state.
|
||||
func processItem(ctx context.Context, deps *workerDeps, item model.ArtworkQueueItem) outcome {
|
||||
repo := deps.ds.Artwork(ctx)
|
||||
|
||||
res, err := resolveItem(ctx, deps.ds, deps.prov, deps.ffmpeg, item, deps.extGate)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
if res.reader == nil {
|
||||
if res.extError {
|
||||
// An external source errored/timed out: never settle on absent, keep serving old state.
|
||||
return outcomeFailed
|
||||
}
|
||||
return writeAbsent(ctx, repo, item)
|
||||
}
|
||||
defer res.reader.Close()
|
||||
|
||||
data, err := readCapped(res.reader)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
log.Debug(ctx, "artwork: read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
|
||||
|
||||
hash, err := HashImage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
|
||||
art, err := repo.GetImage(hash)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Dedup hit: identical bytes already known, reuse dims/mime/blurhash.
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
art, err = decodeArtwork(ctx, hash, data)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
default:
|
||||
log.Warn(ctx, "artwork: failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
art.SizeBytes = int64(len(data))
|
||||
|
||||
sourcePath, refMtime, err := placeBytes(deps.store, art, res, data)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: failed to write image store", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
if err := repo.PutImage(art); err != nil {
|
||||
log.Warn(ctx, "artwork: failed to persist artwork image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
if err := repo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: item.ItemKind,
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
Hash: hash,
|
||||
Source: res.source,
|
||||
SourcePath: sourcePath,
|
||||
RefMtime: refMtime,
|
||||
AttemptedAt: time.Now(),
|
||||
}); err != nil {
|
||||
log.Warn(ctx, "artwork: failed to persist item artwork state", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
if res.extError {
|
||||
return outcomeFoundStale
|
||||
}
|
||||
return outcomeFound
|
||||
}
|
||||
|
||||
// writeAbsent records a known-absent state: every local/external source answered definitively "no".
|
||||
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
||||
err := repo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: item.ItemKind,
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
AttemptedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed
|
||||
}
|
||||
return outcomeAbsent
|
||||
}
|
||||
|
||||
// readCapped reads r, rejecting anything over maxImageBytes.
|
||||
func readCapped(r io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxImageBytes {
|
||||
return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// decodeCapped rejects declared dimensions over maxImagePixels BEFORE the
|
||||
// full-decode allocation, then decodes.
|
||||
func decodeCapped(data []byte) (image.Image, string, error) {
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image config: %w", err)
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
|
||||
return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and a
|
||||
// blurhash computed from a downscaled thumbnail.
|
||||
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
|
||||
img, format, err := decodeCapped(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
thumb := makeThumbnail(img, thumbnailSize)
|
||||
xComp, yComp := blurhash.Components(thumb.Bounds().Dx(), thumb.Bounds().Dy())
|
||||
bh, err := blurhash.Encode(thumb, xComp, yComp)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "artwork: blurhash encoding failed", "hash", hash, err)
|
||||
bh = ""
|
||||
}
|
||||
|
||||
return &model.Artwork{
|
||||
Hash: hash,
|
||||
Mime: mimeForFormat(format),
|
||||
Width: img.Bounds().Dx(),
|
||||
Height: img.Bounds().Dy(),
|
||||
BlurHash: bh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// makeThumbnail downscales img to fit within maxSize on its longest side.
|
||||
// Images within bounds are returned as-is (no upscaling).
|
||||
func makeThumbnail(img image.Image, maxSize int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxSize && h <= maxSize {
|
||||
return toFastScaleType(img)
|
||||
}
|
||||
scale := float64(maxSize) / float64(max(w, h))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// isFileBacked reports whether a resolution's bytes already live in a library/upload
|
||||
// file, so the acquisition must not duplicate them into the content-addressed store.
|
||||
func isFileBacked(source string) bool {
|
||||
return source == "folder" || source == "upload"
|
||||
}
|
||||
|
||||
// placeBytes reports the item's backing-file provenance (folder/upload: image, embedded: audio,
|
||||
// external/generated: none) and writes the bytes into the store for the non-file-backed sources.
|
||||
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {
|
||||
if isFileBacked(res.source) {
|
||||
return res.sourcePath, res.refMtime, nil
|
||||
}
|
||||
if res.source == "embedded" {
|
||||
sourcePath, refMtime = res.sourcePath, res.refMtime
|
||||
}
|
||||
return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// mimeForFormat maps an image.Decode format name to its MIME type; extForMime
|
||||
// in image_store.go performs the inverse for content-addressed file paths.
|
||||
func mimeForFormat(format string) string {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// pngHeaderWithDims builds just a PNG signature + IHDR chunk declaring w×h. DecodeConfig
|
||||
// reads the header without touching pixel data, so the body can be omitted entirely.
|
||||
func pngHeaderWithDims(w, h uint32) []byte {
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 2 // color type: truecolor
|
||||
chunk := append([]byte("IHDR"), ihdr...)
|
||||
out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr)))
|
||||
out = append(out, chunk...)
|
||||
return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk))
|
||||
}
|
||||
|
||||
var _ = Describe("processItem", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
prov *fakeExternalProvider
|
||||
store *ImageStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
repoRoot string
|
||||
deps *workerDeps
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
prov = &fakeExternalProvider{}
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artRepo,
|
||||
}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
deps = &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm}
|
||||
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
})
|
||||
|
||||
It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = store.Open(ia.Hash, art.Mime)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store")
|
||||
})
|
||||
|
||||
It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
folderRepo.result = nil
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||
Expect(out).To(Equal(outcomeFound))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork("al", "al2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("absent: no local source and no external error persists a known-absent state", func() {
|
||||
folderRepo.result = nil
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al3", Name: "Album"},
|
||||
})
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||
Expect(out).To(Equal(outcomeAbsent))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork("al", "al3", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(ia.Source).To(BeEmpty())
|
||||
Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
})
|
||||
|
||||
It("failed-on-extError: leaves the item's state untouched", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al4", Name: "Album"},
|
||||
})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork("al", "al4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||
Expect(out).To(Equal(outcomeFoundStale))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).ToNot(BeEmpty())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
||||
It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}},
|
||||
{ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
out1 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||
Expect(out1).To(Equal(outcomeFound))
|
||||
ia1, err := artRepo.GetItemArtwork("al", "al5", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Poison the stored blurhash: if the second item re-decodes instead of
|
||||
// deduping on hash, this sentinel gets overwritten by a real computed value.
|
||||
poisoned := artRepo.Data[ia1.Hash]
|
||||
poisoned.BlurHash = "SENTINEL"
|
||||
artRepo.Data[ia1.Hash] = poisoned
|
||||
|
||||
out2 := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||
Expect(out2).To(Equal(outcomeFound))
|
||||
ia2, err := artRepo.GetItemArtwork("al", "al6", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
||||
|
||||
reused, err := artRepo.GetImage(ia1.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||
})
|
||||
|
||||
It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() {
|
||||
// Two distinct library files with byte-identical content resolve to the same
|
||||
// hash. Provenance is per-item, so neither file's path may overwrite the other.
|
||||
libRoot := GinkgoT().TempDir()
|
||||
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} {
|
||||
dir := filepath.Join(libRoot, sub)
|
||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||
img := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed())
|
||||
Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed())
|
||||
}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}},
|
||||
{ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}},
|
||||
})
|
||||
|
||||
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
||||
Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})).To(Equal(outcomeFound))
|
||||
iaA, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(iaA.Source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
Expect(iaA.RefMtime).To(Equal(int64(1000)))
|
||||
|
||||
// Poison the shared row's blurhash: the second item must dedup on hash, not re-decode.
|
||||
poisoned := artRepo.Data[iaA.Hash]
|
||||
poisoned.BlurHash = "SENTINEL"
|
||||
artRepo.Data[iaA.Hash] = poisoned
|
||||
|
||||
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
||||
Expect(processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})).To(Equal(outcomeFound))
|
||||
iaB, err := artRepo.GetItemArtwork("al", "alB", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||
Expect(iaB.RefMtime).To(Equal(int64(2000)))
|
||||
|
||||
// The first item's provenance survives the second item processing identical bytes.
|
||||
iaAafter, err := artRepo.GetItemArtwork("al", "alA", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||
Expect(iaAafter.RefMtime).To(Equal(int64(1000)))
|
||||
|
||||
// One shared artwork row, and dedup preserved it untouched.
|
||||
Expect(artRepo.Data).To(HaveLen(1))
|
||||
reused, err := artRepo.GetImage(iaA.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||
})
|
||||
|
||||
It("decode failure on found bytes: fails without writing state", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork("ra", "ra1", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("oversized read: a resolved image larger than the cap fails without writing state", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg")
|
||||
f, err := os.Create(imgPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(f.Truncate(maxImageBytes + 1)).To(Succeed())
|
||||
Expect(f.Close()).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err = artRepo.GetItemArtwork("ra", "big", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("decompression bomb: rejects huge declared dimensions before the full decode", func() {
|
||||
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
|
||||
_, err := decodeArtwork(ctx, "bomb", data)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("dimensions"))
|
||||
})
|
||||
|
||||
It("store write failure: fails without writing state", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
folderRepo.result = nil
|
||||
|
||||
// A store root that is a plain file makes every MkdirAll under it fail.
|
||||
blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir")
|
||||
Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed())
|
||||
deps.store = NewImageStore(blockedRoot)
|
||||
|
||||
out := processItem(ctx, deps, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
_, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// pruneMinAge guards the window between artwork insert and item_artwork upsert.
|
||||
const pruneMinAge = time.Hour
|
||||
|
||||
func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||
repo := ds.Artwork(ctx)
|
||||
|
||||
purged, err := repo.PurgeDanglingItemArtwork()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if purged > 0 {
|
||||
log.Info(ctx, "Prune: purged dangling item artwork state", "count", purged)
|
||||
}
|
||||
|
||||
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
||||
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if queuePurged > 0 {
|
||||
log.Info(ctx, "Prune: purged dangling artwork queue rows", "count", queuePurged)
|
||||
}
|
||||
|
||||
// One grace cutoff for both the DB orphan check and the file sweep: files younger
|
||||
// than the window may belong to acquisitions whose rows aren't committed yet.
|
||||
cutoff := time.Now().Add(-pruneMinAge)
|
||||
candidates, err := repo.GetOrphanHashes(cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
arts, err := repo.GetImages(candidates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repo.DeleteOrphans(cutoff, candidates); err != nil {
|
||||
return err
|
||||
}
|
||||
// DeleteOrphans may spare candidates reacquired since the snapshot; only remove files
|
||||
// for rows actually gone (absent from the post-delete re-read).
|
||||
survivors, err := repo.GetImages(candidates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed := 0
|
||||
for _, h := range candidates {
|
||||
if _, ok := survivors[h]; ok {
|
||||
continue
|
||||
}
|
||||
// A spared fresh file is at worst a stray a later sweep reclaims;
|
||||
// Worker.RunPrune serializes prune against in-flight acquisitions.
|
||||
if err := store.Remove(h, arts[h].Mime, cutoff); err != nil {
|
||||
log.Warn(ctx, "Prune: could not remove artwork file", "hash", h, err)
|
||||
}
|
||||
removed++
|
||||
}
|
||||
log.Info(ctx, "Prune: removed orphan artwork", "count", removed)
|
||||
}
|
||||
|
||||
mimes, err := repo.GetAllMimes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := store.Sweep(cutoff, func(hash, ext string) bool {
|
||||
// A known hash under a stale extension is a superseded mime variant — reclaim it.
|
||||
m, ok := mimes[hash]
|
||||
return ok && ext == extForMime(m)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if removed > 0 {
|
||||
log.Info(ctx, "Prune: swept stray artwork files", "count", removed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type flakyGetArtworkRepo struct {
|
||||
*tests.MockArtworkRepo
|
||||
}
|
||||
|
||||
func (f *flakyGetArtworkRepo) GetAllMimes() (map[string]string, error) {
|
||||
return nil, errors.New("db locked")
|
||||
}
|
||||
|
||||
var _ = Describe("Prune", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var store *ImageStore
|
||||
var awRepo *tests.MockArtworkRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
})
|
||||
|
||||
// PutImage refreshes created_at like the SQL repo, so fixtures are aged directly.
|
||||
ageArtwork := func(h string, t time.Time) {
|
||||
a := awRepo.Data[h]
|
||||
a.CreatedAt = t
|
||||
awRepo.Data[h] = a
|
||||
}
|
||||
|
||||
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||
awRepo.ExistingIDs = map[string]map[string]bool{
|
||||
"al": {},
|
||||
"ar": {"live-artist": true},
|
||||
}
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetItemArtwork("al", "gone-album", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = awRepo.GetItemArtwork("ar", "gone-artist", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = awRepo.GetItemArtwork("ar", "live-artist", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||
queueRepo := tests.CreateMockArtworkQueueRepo()
|
||||
Expect(queueRepo.Enqueue(
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
||||
)).To(Succeed())
|
||||
queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}}
|
||||
ds.MockedArtworkQueue = queueRepo
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil())
|
||||
Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("deletes orphan rows and their store files, keeps referenced ones", func() {
|
||||
data := []byte("orphan-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, old)
|
||||
awRepo.OrphanHashes = []string{h}
|
||||
|
||||
kept := []byte("kept-bytes")
|
||||
hk, _ := HashImage(bytes.NewReader(kept))
|
||||
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(h, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(hk, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("spares a candidate reacquired between snapshot and delete", func() {
|
||||
data := []byte("reacquired-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||
awRepo.OrphanHashes = []string{h}
|
||||
// Reacquisition: an item now references the hash the snapshot flagged as orphan.
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("spares a candidate whose row was freshly recreated (created_at inside the grace window)", func() {
|
||||
data := []byte("fresh-reacquired-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
// Reacquisition refreshed created_at after the snapshot; still unreferenced.
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
awRepo.OrphanHashes = []string{h}
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := awRepo.GetImage(h)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("spares an orphan file freshly touched by an overlapping acquisition", func() {
|
||||
data := []byte("racing-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||
awRepo.OrphanHashes = []string{h}
|
||||
// The row is legitimately orphaned, but a concurrent acquisition just touched the
|
||||
// file's mtime (duplicate Write) and is about to commit a row referencing it.
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("sweeps store files that have no artwork row", func() {
|
||||
stray := []byte("no-row-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(stray))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := store.Open(h, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("sweeps an obsolete mime variant of a reacquired hash", func() {
|
||||
data := []byte("variant-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed())
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||
// The row records the current mime; the .png file is a superseded variant.
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).To(Succeed())
|
||||
|
||||
_, err := store.Open(h, "image/png")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("warns and continues past a store.Remove failure instead of aborting the loop", func() {
|
||||
tests.SkipOnWindows("uses Unix file permission bits")
|
||||
if os.Geteuid() == 0 {
|
||||
Skip("read-only dir cannot block root (e.g. tests in a container)")
|
||||
}
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
|
||||
blocked := []byte("blocked-bytes")
|
||||
hb, _ := HashImage(bytes.NewReader(blocked))
|
||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(hb, old)
|
||||
|
||||
good := []byte("good-bytes")
|
||||
hg, _ := HashImage(bytes.NewReader(good))
|
||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
||||
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||
ageArtwork(hg, old)
|
||||
|
||||
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
||||
shardDir := filepath.Dir(store.path(hb, "image/jpeg"))
|
||||
Expect(os.Chmod(shardDir, 0500)).To(Succeed())
|
||||
DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) })
|
||||
|
||||
// hb (blocked) is processed first: if store.Remove's failure aborted the loop
|
||||
// instead of warning and continuing, hg would never be reached.
|
||||
awRepo.OrphanHashes = []string{hb, hg}
|
||||
|
||||
// Prune still errors: Sweep independently revisits hb's leftover file and,
|
||||
// unlike the loop below, has no warn-and-continue fallback of its own.
|
||||
err := Prune(context.Background(), ds, store)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// hg: reached and fully pruned despite being queued after the failing hb -
|
||||
// proof the loop didn't return/break on the first Remove error.
|
||||
_, err = awRepo.GetImage(hg)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = store.Open(hg, "image/jpeg")
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
// hb: row still purged (DeleteOrphans doesn't depend on file removal), but the
|
||||
// file itself survives since store.Remove failed and only warned.
|
||||
_, err = awRepo.GetImage(hb)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
rc, err := store.Open(hb, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("never sweeps files on a transient DB error", func() {
|
||||
ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()}
|
||||
|
||||
data := []byte("live-bytes")
|
||||
h, _ := HashImage(bytes.NewReader(data))
|
||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||
|
||||
Expect(Prune(context.Background(), ds, store)).ToNot(Succeed())
|
||||
|
||||
rc, err := store.Open(h, "image/jpeg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc.Close()
|
||||
})
|
||||
})
|
||||
@@ -113,12 +113,28 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
parent, err := albumRootParent(ctx, ds, folders, folderIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
folderIDSet := make(map[string]bool, len(folderIDs))
|
||||
for _, id := range folderIDs {
|
||||
folderIDSet[id] = true
|
||||
}
|
||||
if parent != nil {
|
||||
folders = append(folders, *parent)
|
||||
|
||||
// Check if all folders share a common parent that is not already included.
|
||||
// This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg"
|
||||
// when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
|
||||
// For single-folder albums, the parent is only included when the folder has no
|
||||
// images of its own (indicating a disc subfolder needing parent artwork).
|
||||
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
|
||||
if len(folders) >= 2 || !anyFolderHasImages(folders) {
|
||||
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
} else if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if parentFolder != nil && parentFolder.ParentID != "" {
|
||||
folders = append(folders, *parentFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paths []string
|
||||
@@ -143,50 +159,6 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// albumRootParent returns the common parent of the album's folders when it
|
||||
// qualifies as the album's root folder (e.g. "Artist/Album" above disc
|
||||
// subfolders), or nil when there is no such parent. This finds cover art in
|
||||
// the album root folder when tracks live in disc subfolders, like
|
||||
// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and
|
||||
// "Artist/Album/CD2/". The parent must look like an album root, not an
|
||||
// artist-level folder — it qualifies only when it holds no audio belonging to
|
||||
// other albums — so artist images are never served as album art.
|
||||
func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) {
|
||||
folderIDSet := make(map[string]bool, len(folderIDs))
|
||||
for _, id := range folderIDs {
|
||||
folderIDSet[id] = true
|
||||
}
|
||||
commonParentID := commonParentFolder(folders, folderIDSet)
|
||||
if commonParentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// Single-folder albums only use the parent when the folder has no images
|
||||
// of its own (indicating a disc subfolder needing parent artwork).
|
||||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||
return nil, nil
|
||||
}
|
||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.ParentID == "" {
|
||||
// The library root can never be an album root
|
||||
return nil, nil
|
||||
}
|
||||
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasOtherAudio {
|
||||
return nil, nil
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
func anyFolderHasImages(folders []model.Folder) bool {
|
||||
for _, f := range folders {
|
||||
if len(f.ImageFiles) > 0 {
|
||||
|
||||
@@ -339,61 +339,6 @@ var _ = Describe("Album Artwork Reader", func() {
|
||||
Expect(repo.getCallCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not include parent images when other albums' audio lives under the parent", func() {
|
||||
// Simulates: Artist/folder.jpg with Artist/Album (no images) and
|
||||
// another album's tracks elsewhere under the artist folder
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "artistFolder",
|
||||
Path: ".",
|
||||
Name: "Artist",
|
||||
ParentID: "libraryRoot",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"folder.jpg"},
|
||||
}
|
||||
repo.hasOtherAudio = true
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("propagates errors from the album-root check", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
ID: "folder1",
|
||||
Path: "Artist/Album",
|
||||
Name: "disc1",
|
||||
ParentID: "albumFolder",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{},
|
||||
},
|
||||
}
|
||||
repo.parentResult = &model.Folder{
|
||||
ID: "albumFolder",
|
||||
Path: "Artist",
|
||||
Name: "Album",
|
||||
ParentID: "artistFolder",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
repo.otherAudioErr = errors.New("db connection failed")
|
||||
|
||||
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).To(MatchError("db connection failed"))
|
||||
})
|
||||
|
||||
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
|
||||
@@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.DataFolder = tempDir
|
||||
|
||||
// Create the artwork/artist directory
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
@@ -702,20 +702,12 @@ type fakeFolderRepo struct {
|
||||
getErr error
|
||||
getCallCount int
|
||||
err error
|
||||
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root
|
||||
// check). False means the parent qualifies as an album root.
|
||||
hasOtherAudio bool
|
||||
otherAudioErr error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||
return f.hasOtherAudio, f.otherAudioErr
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
|
||||
f.getCallCount++
|
||||
if f.getErr != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.DataFolder = tempDir
|
||||
|
||||
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ import (
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
// gen2brain/webp selects native (purego/libwebp) vs WASM in its own
|
||||
// package init() and exposes the result only via webp.Dynamic(); there is
|
||||
// no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
|
||||
// crashes (issue #5597), so those builds must be compiled with the
|
||||
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
|
||||
// error here and forces the safe WASM path.
|
||||
if err := webp.Dynamic(); err != nil {
|
||||
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
|
||||
} else {
|
||||
@@ -132,22 +126,6 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader
|
||||
return resizeStaticImage(data, a.size, a.square)
|
||||
}
|
||||
|
||||
// toFastScaleType converts images whose concrete type has no optimized scaler
|
||||
// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed
|
||||
// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale
|
||||
// falls back to a generic per-pixel At()/RGBA() loop that is several times
|
||||
// slower. Fast-path types are returned unchanged.
|
||||
func toFastScaleType(img image.Image) image.Image {
|
||||
switch img.(type) {
|
||||
case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr:
|
||||
return img
|
||||
default:
|
||||
rgba := image.NewRGBA(img.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
|
||||
return rgba
|
||||
}
|
||||
}
|
||||
|
||||
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@@ -185,7 +163,6 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
dstRect = dst.Bounds()
|
||||
}
|
||||
original = toFastScaleType(original)
|
||||
xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// resolution is one attempted acquisition outcome for an entity.
|
||||
type resolution struct {
|
||||
reader io.ReadCloser // nil when no source yielded an image
|
||||
source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated"
|
||||
sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise
|
||||
refMtime int64 // mtime of sourcePath at resolution time; 0 when no sourcePath
|
||||
// external source errored/timed out. With no reader: forces failed (never absent).
|
||||
// On a hit: a higher-priority external step failed—serve this, but retry later.
|
||||
extError bool
|
||||
}
|
||||
|
||||
// extGateFunc is an alias for the external-step wrapper the worker injects (rate
|
||||
// limiter + circuit breaker); resolveItem defaults to a plain passthrough.
|
||||
type extGateFunc = func(func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error)
|
||||
|
||||
func passthroughExtGate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
return f()
|
||||
}
|
||||
|
||||
// resolveItem walks the kind's priority chain and returns the first hit.
|
||||
func resolveItem(ctx context.Context, ds model.DataStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg, item model.ArtworkQueueItem, extGate extGateFunc) (resolution, error) {
|
||||
if extGate == nil {
|
||||
extGate = passthroughExtGate
|
||||
}
|
||||
switch item.ItemKind {
|
||||
case "al":
|
||||
return resolveAlbum(ctx, ds, prov, ffmpeg, item.ItemID, extGate)
|
||||
case "ar":
|
||||
return resolveArtist(ctx, ds, prov, ffmpeg, item.ItemID, extGate)
|
||||
case "pl":
|
||||
return resolvePlaylist(ctx, ds, prov, ffmpeg, item.ItemID, extGate)
|
||||
case "ra":
|
||||
return resolveRadio(ctx, ds, item.ItemID)
|
||||
default:
|
||||
return resolution{}, fmt.Errorf("resolveItem: kind %q is not resolvable by the worker", item.ItemKind)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAlbum ports the folder/embedded/external selection from
|
||||
// reader_album.go, walking conf.Server.CoverArtPriority.
|
||||
func resolveAlbum(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, albumID string, extGate extGateFunc) (resolution, error) {
|
||||
al, err := ds.Album(ctx).Get(albumID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, *al)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
lib, err := loadLibraryView(ctx, ds, al.LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var extErr bool
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
if res, ok := resolveEmbedded(ctx, lib, ffm, al.EmbedArtPath); ok {
|
||||
res.extError = extErr
|
||||
return res, nil
|
||||
}
|
||||
case pattern == "external":
|
||||
if res, ok, isErr := resolveExternalStep(extGate, fromAlbumExternalSource(ctx, *al, prov)); ok {
|
||||
return res, nil
|
||||
} else if isErr {
|
||||
extErr = true
|
||||
}
|
||||
case len(imgFiles) > 0:
|
||||
if res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern); ok {
|
||||
res.extError = extErr
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolution{extError: extErr}, nil
|
||||
}
|
||||
|
||||
// resolveArtist ports the upload/folder/external selection from
|
||||
// reader_artist.go: upload always wins, then conf.Server.ArtistArtPriority.
|
||||
func resolveArtist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, artistID string, extGate extGateFunc) (resolution, error) {
|
||||
ar, err := ds.Artist(ctx).Get(artistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
if res, ok := resolveLocalFile(ar.UploadedImagePath(), "upload"); ok {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Only consider albums where the artist is the sole album artist, same as reader_artist.go.
|
||||
als, err := ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"album_artist_id": artistID},
|
||||
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
albumPaths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, als...)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
artistFolder, _, err := loadArtistFolder(ctx, ds, als, albumPaths)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
var lib libraryView
|
||||
if len(als) > 0 {
|
||||
lib, err = loadLibraryView(ctx, ds, als[0].LibraryID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
}
|
||||
|
||||
var extErr bool
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
if res, ok, isErr := resolveExternalStep(extGate, fromArtistExternalResult(ctx, *ar, prov)); ok {
|
||||
return res, nil
|
||||
} else if isErr {
|
||||
extErr = true
|
||||
}
|
||||
case pattern == "image-folder":
|
||||
if res, ok := resolveArtistImageFolder(ar); ok {
|
||||
res.extError = extErr
|
||||
return res, nil
|
||||
}
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
if lib.FS == nil {
|
||||
continue
|
||||
}
|
||||
if res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")); ok {
|
||||
res.extError = extErr
|
||||
return res, nil
|
||||
}
|
||||
default:
|
||||
if lib.FS == nil || artistFolder == "" {
|
||||
continue
|
||||
}
|
||||
if res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern); ok {
|
||||
res.extError = extErr
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolution{extError: extErr}, nil
|
||||
}
|
||||
|
||||
// resolvePlaylist ports reader_playlist.go's chain: uploaded image, sidecar,
|
||||
// ExternalImageURL, then the generated 2x2 grid sourced through resolveAlbum.
|
||||
func resolvePlaylist(ctx context.Context, ds model.DataStore, prov external.Provider, ffm ffmpeg.FFmpeg, playlistID string, extGate extGateFunc) (resolution, error) {
|
||||
pl, err := ds.Playlist(ctx).Get(playlistID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var extErr bool
|
||||
if res, ok := resolveLocalFile(pl.UploadedImagePath(), "upload"); ok {
|
||||
return res, nil
|
||||
}
|
||||
if res, ok := resolveLocalFile(findPlaylistSidecarPath(ctx, pl.Path), "folder"); ok {
|
||||
return res, nil
|
||||
}
|
||||
if res, ok, isErr := resolveExternalStep(extGate, fromPlaylistExternalSource(ctx, *pl)); ok {
|
||||
return res, nil
|
||||
} else if isErr {
|
||||
extErr = true
|
||||
}
|
||||
|
||||
albumIDs, err := ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
|
||||
var tiles []image.Image
|
||||
var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight
|
||||
for _, albumID := range albumIDs {
|
||||
res, err := resolveAlbum(ctx, ds, prov, ffm, albumID, extGate)
|
||||
if err != nil {
|
||||
if tileErr == nil {
|
||||
tileErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.extError {
|
||||
extErr = true
|
||||
}
|
||||
if res.reader == nil {
|
||||
continue
|
||||
}
|
||||
tile, decErr := decodeTile(res.reader)
|
||||
res.reader.Close()
|
||||
if decErr == nil {
|
||||
tiles = append(tiles, tile)
|
||||
}
|
||||
if len(tiles) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(tiles) == 0 {
|
||||
// A tile-level failure must never resolve as a clean absent: propagate
|
||||
// internal errors, and force extError for external ones.
|
||||
if tileErr != nil {
|
||||
return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr)
|
||||
}
|
||||
return resolution{extError: extErr}, nil
|
||||
}
|
||||
// Grow to 4 tiles by repeating what we have, mirroring reader_playlist.go's loadTiles.
|
||||
switch len(tiles) {
|
||||
case 2:
|
||||
tiles = append(tiles, tiles[1], tiles[0])
|
||||
case 3:
|
||||
tiles = append(tiles, tiles[0])
|
||||
}
|
||||
r, err := assembleTiles(tiles)
|
||||
if err != nil {
|
||||
return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolveItem error
|
||||
}
|
||||
return resolution{reader: r, source: "generated", extError: extErr}, nil
|
||||
}
|
||||
|
||||
// resolveRadio ports reader_radio.go: only an uploaded image, no fallback.
|
||||
func resolveRadio(ctx context.Context, ds model.DataStore, radioID string) (resolution, error) {
|
||||
r, err := ds.Radio(ctx).Get(radioID)
|
||||
if err != nil {
|
||||
return resolution{}, err
|
||||
}
|
||||
res, _ := resolveLocalFile(r.UploadedImagePath(), "upload")
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// resolveExternalStep runs an external sourceFunc through extGate, shared by
|
||||
// resolveAlbum and resolveArtist. ok reports a hit; extErr reports a
|
||||
// non-not-found error (a not-found is a definitive "no", not a failure).
|
||||
func resolveExternalStep(extGate extGateFunc, sf func() (io.ReadCloser, string, error)) (res resolution, ok bool, extErr bool) {
|
||||
r, path, err := extGate(sf)
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: "external", sourcePath: path}, true, false
|
||||
}
|
||||
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
|
||||
}
|
||||
|
||||
// fromPlaylistExternalSource mirrors reader_playlist.go's ExternalImageURL step:
|
||||
// a remote URL (gated) when M3U external art is enabled, else a local file path.
|
||||
func fromPlaylistExternalSource(ctx context.Context, pl model.Playlist) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imgURL := pl.ExternalImageURL
|
||||
if imgURL == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
parsed, err := url.Parse(imgURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if parsed.Scheme == "http" || parsed.Scheme == "https" {
|
||||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||||
return nil, "", nil
|
||||
}
|
||||
return fetchPlaylistImageURL(ctx, parsed)
|
||||
}
|
||||
// A missing/unreadable local file is a definitive miss, not a transient
|
||||
// failure to retry: swallow the open error and fall through to the grid.
|
||||
r, path, _ := fromLocalFile(imgURL)()
|
||||
return r, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Like sources.go's fromURL but maps 404/410 to ErrNotFound (definitive), so a stale M3U
|
||||
// cover URL falls through to the grid instead of retrying forever and tripping the breaker.
|
||||
func fetchPlaylistImageURL(ctx context.Context, imageURL *url.URL) (io.ReadCloser, string, error) {
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL.String(), nil)
|
||||
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
resp.Body.Close()
|
||||
return nil, "", model.ErrNotFound
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageURL, resp.Status)
|
||||
}
|
||||
return resp.Body, imageURL.String(), nil
|
||||
}
|
||||
|
||||
func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) {
|
||||
if embedRel == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
abs := lib.Abs(embedRel)
|
||||
for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} {
|
||||
if r, _, _ := sf(); r != nil {
|
||||
return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true
|
||||
}
|
||||
}
|
||||
return resolution{}, false
|
||||
}
|
||||
|
||||
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
|
||||
r, path, _ := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
|
||||
if r == nil {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
|
||||
}
|
||||
|
||||
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
|
||||
folder := conf.Server.ArtistImageFolder
|
||||
if folder == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder")
|
||||
}
|
||||
|
||||
func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) {
|
||||
r, path, _ := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)()
|
||||
if r == nil {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
// resolveLocalFile opens an absolute path directly (uploads, image-folder). A
|
||||
// missing or unreadable path is "no source", not an error.
|
||||
func resolveLocalFile(path, source string) (resolution, bool) {
|
||||
if path == "" {
|
||||
return resolution{}, false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return resolution{}, false
|
||||
}
|
||||
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
func mtimeOf(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.ModTime().Unix()
|
||||
}
|
||||
|
||||
// mtimeViaFS stats through the library FS instead of a joined absolute path,
|
||||
// since library roots in tests may not be real OS paths (e.g. testfile://).
|
||||
func mtimeViaFS(fsys fs.FS, name string) int64 {
|
||||
if fsys == nil || name == "" {
|
||||
return 0
|
||||
}
|
||||
info, err := fs.Stat(fsys, name)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.ModTime().Unix()
|
||||
}
|
||||
|
||||
// decodeTile and assembleTiles mirror playlistArtworkReader's createTile/
|
||||
// createTiledImage, reusing the same rect/fillCenter cropping helpers.
|
||||
// decodeTile runs on every sampled album's resolved bytes before processItem's
|
||||
// own maxImageBytes/maxImagePixels guards apply, so it enforces them itself too.
|
||||
func decodeTile(r io.ReadCloser) (image.Image, error) {
|
||||
data, err := readCapped(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, _, err := decodeCapped(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillCenter(img, tileSize/2, tileSize/2), nil
|
||||
}
|
||||
|
||||
func assembleTiles(tiles []image.Image) (io.ReadCloser, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
var err error
|
||||
if len(tiles) == 4 {
|
||||
rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
|
||||
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
|
||||
err = png.Encode(buf, rgba)
|
||||
} else {
|
||||
err = png.Encode(buf, tiles[0])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fakeExternalProvider is a minimal external.Provider stub for resolve_test.go;
|
||||
// only AlbumImage/ArtistImage are exercised by the resolvers.
|
||||
type fakeExternalProvider struct {
|
||||
external.Provider
|
||||
albumImage func(ctx context.Context, id string) (*url.URL, error)
|
||||
artistImage func(ctx context.Context, id string) (*url.URL, error)
|
||||
}
|
||||
|
||||
func (f *fakeExternalProvider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
if f.albumImage != nil {
|
||||
return f.albumImage(ctx, id)
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeExternalProvider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
if f.artistImage != nil {
|
||||
return f.artistImage(ctx, id)
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeExternalProvider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) {
|
||||
return f.ArtistImage(ctx, id)
|
||||
}
|
||||
|
||||
var _ = Describe("resolveItem", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
prov *fakeExternalProvider
|
||||
repoRoot string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
prov = &fakeExternalProvider{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
}
|
||||
})
|
||||
|
||||
Describe("kind dispatch", func() {
|
||||
It("returns an error for kinds the worker never enqueues", func() {
|
||||
_, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "x"}, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("album", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
})
|
||||
|
||||
It("resolves folder art from the library FS", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("falls back to embedded art when no folder image matches", func() {
|
||||
folderRepo.result = nil
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("embedded"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||
Expect(res.refMtime).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("sets extError when the external source errors without being not-found", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al3", Name: "Album"},
|
||||
})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not set extError when the external source reports not-found", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al4", Name: "Album"},
|
||||
})
|
||||
// prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("carries extError onto a fallback folder hit after a transient external failure", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al6", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
// prov.albumImage left nil -> fakeExternalProvider returns model.ErrNotFound
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("routes the external step through a custom extGate", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al5", Name: "Album"},
|
||||
})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
var extGateCalls int
|
||||
extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
extGateCalls++
|
||||
return f()
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}, extGate)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
Expect(extGateCalls).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("artist", func() {
|
||||
It("resolves the uploaded image before any priority chain lookup", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
||||
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("upload"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("falls through to the ArtistArtPriority chain when there is no upload", func() {
|
||||
conf.Server.ArtistArtPriority = "album/artist.*"
|
||||
folderRepo.result = []model.Folder{{
|
||||
LibraryPath: testFileLibPath(repoRoot),
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{
|
||||
{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}},
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
|
||||
})
|
||||
|
||||
It("sets extError when the external source errors without being not-found", func() {
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
prov.artistImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not set extError when the external source reports not-found", func() {
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
// prov.artistImage left nil -> fakeExternalProvider returns model.ErrNotFound
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("routes the external step through a custom extGate", func() {
|
||||
conf.Server.ArtistArtPriority = "external"
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
|
||||
ds.MockedArtist = artistRepo
|
||||
prov.artistImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
var extGateCalls int
|
||||
extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
extGateCalls++
|
||||
return f()
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}, extGate)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
Expect(extGateCalls).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("radio", func() {
|
||||
It("yields an empty resolution when there is no uploaded image", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(Equal(resolution{}))
|
||||
})
|
||||
|
||||
It("resolves the uploaded image when set", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
|
||||
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}}
|
||||
ds.MockedRadio = radioRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("upload"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("playlist", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "t1", Name: "T1", FolderIDs: []string{"f1"}},
|
||||
{ID: "t2", Name: "T2", FolderIDs: []string{"f1"}},
|
||||
{ID: "t3", Name: "T3", FolderIDs: []string{"f1"}},
|
||||
{ID: "t4", Name: "T4", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("yields a generated grid from up to 4 album tiles",
|
||||
func(albumIDs []string, expectedSize int) {
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("generated"))
|
||||
|
||||
img, format, err := image.Decode(res.reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Dx()).To(Equal(expectedSize))
|
||||
Expect(img.Bounds().Dy()).To(Equal(expectedSize))
|
||||
},
|
||||
// tileSize-1: the 4-tile canvas is built as [0, tileSize-1], matching
|
||||
// reader_playlist.go's createTiledImage exactly.
|
||||
Entry("1 album -> single tile", []string{"t1"}, tileSize/2),
|
||||
Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1),
|
||||
Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1),
|
||||
Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1),
|
||||
)
|
||||
|
||||
It("resolves the uploaded image before the generated grid", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed())
|
||||
imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed())
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("upload"))
|
||||
Expect(res.sourcePath).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("resolves a sidecar image next to the playlist file before the grid", func() {
|
||||
plDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed())
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("folder"))
|
||||
Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg"))
|
||||
})
|
||||
|
||||
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
var extGateCalls int
|
||||
extGate := func(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
extGateCalls++
|
||||
return nil, "", errors.New("network down")
|
||||
}
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}, extGate)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
Expect(extGateCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() {
|
||||
folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).ToNot(BeNil())
|
||||
defer res.reader.Close()
|
||||
Expect(res.source).To(Equal("generated"))
|
||||
Expect(res.extError).To(BeFalse())
|
||||
})
|
||||
|
||||
It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
})
|
||||
|
||||
It("yields an empty resolution when no album has art", func() {
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "empty1", Name: "Empty"},
|
||||
})
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
folderRepo.result = nil
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.source).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("skips a grid tile whose declared dimensions are a decompression bomb", func() {
|
||||
// End-to-end regression: a bomb-declaring tile must not break the grid.
|
||||
libRoot := GinkgoT().TempDir()
|
||||
Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed())
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
|
||||
folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}}
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.reader).To(BeNil())
|
||||
Expect(res.source).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not resolve as absent when every sampled album fails to resolve", func() {
|
||||
// "missing1"/"missing2" are not in MockAlbumRepo's data, so resolveAlbum
|
||||
// returns a genuine (non-external) error for every sampled tile.
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
res, err := resolveItem(ctx, ds, prov, ffm, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(res).To(Equal(resolution{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// decodeTile runs on every sampled album's resolved bytes before processItem's
|
||||
// own guards apply, so it must enforce the same caps independently.
|
||||
var _ = Describe("decodeTile", func() {
|
||||
It("rejects a decompression bomb before the full decode", func() {
|
||||
data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap
|
||||
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("dimensions"))
|
||||
})
|
||||
|
||||
It("rejects a tile larger than the size cap", func() {
|
||||
data := bytes.Repeat([]byte{0}, maxImageBytes+1)
|
||||
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -198,18 +198,6 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider ext
|
||||
}
|
||||
}
|
||||
|
||||
// fromArtistExternalResult is the worker's artist external step: via ArtistImageResult a
|
||||
// transient agent failure surfaces as an error (extError) rather than settling as absent.
|
||||
func fromArtistExternalResult(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := provider.ArtistImageResult(ctx, ar.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return fromURL(ctx, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := provider.AlbumImage(ctx, al.ID)
|
||||
|
||||
@@ -8,6 +8,4 @@ var Set = wire.NewSet(
|
||||
NewArtwork,
|
||||
GetImageCache,
|
||||
NewCacheWarmer,
|
||||
NewWorker,
|
||||
ProvideImageStore,
|
||||
)
|
||||
@@ -1,257 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
workerPollInterval = 5 * time.Second
|
||||
backoffBase = 5 * time.Minute
|
||||
backoffCap = 48 * time.Hour
|
||||
breakerThreshold = 5
|
||||
breakerProbeAfter = time.Minute
|
||||
)
|
||||
|
||||
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
|
||||
|
||||
// Worker drains the artwork queue through processItem: the external step is rate-limited
|
||||
// and circuit-broken, and prune is serialized against in-flight acquisitions via pruneMu.
|
||||
type Worker struct {
|
||||
deps workerDeps
|
||||
limiter *rate.Limiter
|
||||
breaker *breaker
|
||||
pruneMu sync.RWMutex
|
||||
wake chan struct{}
|
||||
runCtx context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
inFlight map[string]struct{}
|
||||
}
|
||||
|
||||
func NewWorker(ds model.DataStore, store *ImageStore, prov external.Provider, ffmpeg ffmpeg.FFmpeg) *Worker {
|
||||
rps := conf.Server.ArtworkExternalMaxRPS
|
||||
limit := rate.Inf // 0 or negative disables the external throttle
|
||||
if rps > 0 {
|
||||
limit = rate.Limit(rps)
|
||||
}
|
||||
w := &Worker{
|
||||
deps: workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffmpeg},
|
||||
limiter: rate.NewLimiter(limit, max(1, rps)),
|
||||
breaker: newBreaker(),
|
||||
wake: make(chan struct{}, 1),
|
||||
runCtx: context.Background(),
|
||||
inFlight: map[string]struct{}{},
|
||||
}
|
||||
w.deps.extGate = w.gate
|
||||
return w
|
||||
}
|
||||
|
||||
// Run blocks draining the queue until ctx is cancelled. It exits cleanly with no
|
||||
// leaked goroutines: each drain waits for its batch before the loop can return.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
w.runCtx = ctx
|
||||
concurrency := max(1, conf.Server.ArtworkWorkerConcurrency)
|
||||
ticker := time.NewTicker(workerPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
n, err := w.drain(ctx, concurrency)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
log.Warn(ctx, "artwork: worker drain failed", err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if n > 0 {
|
||||
continue // keep draining while the queue has ready work
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
case <-w.wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bump enqueues an item at the highest priority and wakes the drain loop. It is
|
||||
// non-blocking: a wake already pending is enough.
|
||||
func (w *Worker) Bump(kind, id string) {
|
||||
item := model.ArtworkQueueItem{
|
||||
ItemKind: kind,
|
||||
ItemID: id,
|
||||
ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump,
|
||||
}
|
||||
if err := w.deps.ds.ArtworkQueue(context.Background()).Enqueue(item); err != nil {
|
||||
log.Warn("artwork: could not bump queue item", "kind", kind, "id", id, err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// RunPrune runs Prune under the worker's write lock, so no acquisition can place
|
||||
// a file while orphans are being reclaimed. This is the only sanctioned prune path.
|
||||
func (w *Worker) RunPrune(ctx context.Context) error {
|
||||
w.pruneMu.Lock()
|
||||
defer w.pruneMu.Unlock()
|
||||
return Prune(ctx, w.deps.ds, w.deps.store)
|
||||
}
|
||||
|
||||
func (w *Worker) drain(ctx context.Context, concurrency int) (int, error) {
|
||||
// Resolved per drain, not once in Run: the worker starts at boot, possibly before any
|
||||
// admin exists, so a late-created admin is picked up on the next poll (private playlists).
|
||||
ctx = auth.WithAdminUser(ctx, w.deps.ds)
|
||||
batch, err := w.deps.ds.ArtworkQueue(ctx).DequeueBatch(2 * concurrency)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
items := w.claim(batch)
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(it model.ArtworkQueueItem) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer w.release(it)
|
||||
w.process(ctx, it)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) {
|
||||
if item.ImageType == "" {
|
||||
item.ImageType = model.ImageTypePrimary
|
||||
}
|
||||
w.pruneMu.RLock()
|
||||
out := processItem(ctx, &w.deps, item)
|
||||
w.pruneMu.RUnlock()
|
||||
|
||||
queue := w.deps.ds.ArtworkQueue(ctx)
|
||||
switch out {
|
||||
case outcomeFound, outcomeAbsent:
|
||||
// DeleteIfUnchanged, not Delete: a scan that re-enqueued this row mid-flight reset
|
||||
// its retry_at, so the row survives here and the next drain re-resolves it.
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
log.Warn(ctx, "artwork: could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
case outcomeFoundStale, outcomeFailed:
|
||||
// MarkFailedIfUnchanged, not MarkFailed: a scan that re-enqueued this row mid-flight reset
|
||||
// retry_at, so stale backoff must not stomp its fresh, immediate eligibility.
|
||||
retryAt := time.Now().Add(backoff(item.Attempts))
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
|
||||
log.Warn(ctx, "artwork: could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// claim reserves items not already in flight, so a row appearing twice within a single
|
||||
// batch is processed once.
|
||||
func (w *Worker) claim(batch []model.ArtworkQueueItem) []model.ArtworkQueueItem {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
var out []model.ArtworkQueueItem
|
||||
for _, it := range batch {
|
||||
k := queueKey(it)
|
||||
if _, busy := w.inFlight[k]; busy {
|
||||
continue
|
||||
}
|
||||
w.inFlight[k] = struct{}{}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *Worker) release(it model.ArtworkQueueItem) {
|
||||
w.mu.Lock()
|
||||
delete(w.inFlight, queueKey(it))
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func queueKey(it model.ArtworkQueueItem) string {
|
||||
return it.ItemKind + "|" + it.ItemID + "|" + it.ImageType
|
||||
}
|
||||
|
||||
// gate wraps the external step with the rate limiter and circuit breaker, matching
|
||||
// extGateFunc so it can be injected via workerDeps.extGate.
|
||||
func (w *Worker) gate(f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
if !w.breaker.allow() {
|
||||
return nil, "", errBreakerOpen
|
||||
}
|
||||
if err := w.limiter.Wait(w.runCtx); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
r, path, err := f()
|
||||
w.breaker.record(err)
|
||||
return r, path, err
|
||||
}
|
||||
|
||||
// backoffFor returns min(5m×4^n, 48h) scaled by (1+jitter), with jitter in [-0.2, 0.2].
|
||||
func backoffFor(attempts int, jitter float64) time.Duration {
|
||||
d := math.Min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(backoffCap))
|
||||
return time.Duration(d * (1 + jitter))
|
||||
}
|
||||
|
||||
func backoff(attempts int) time.Duration {
|
||||
return backoffFor(attempts, rand.Float64()*0.4-0.2) //nolint:gosec // retry jitter, not security-sensitive
|
||||
}
|
||||
|
||||
// breaker opens after breakerThreshold consecutive external errors and admits a
|
||||
// single probe once breakerProbeAfter has elapsed; a success re-closes it.
|
||||
type breaker struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
func newBreaker() *breaker { return &breaker{} }
|
||||
|
||||
func (b *breaker) allow() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.failures < breakerThreshold {
|
||||
return true
|
||||
}
|
||||
if time.Since(b.openedAt) >= breakerProbeAfter {
|
||||
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *breaker) record(err error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
// A not-found is a definitive answer, not a fault; only real errors trip the breaker.
|
||||
if err == nil || errors.Is(err, model.ErrNotFound) {
|
||||
b.failures = 0
|
||||
return
|
||||
}
|
||||
b.failures++
|
||||
if b.failures == breakerThreshold {
|
||||
b.openedAt = time.Now()
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// soakCycles is deliberately >2000: this is a leak regression guard, not a
|
||||
// performance benchmark, so it favors a stable signal over raw speed.
|
||||
const soakCycles = 2200
|
||||
|
||||
var _ = Describe("Worker soak", func() {
|
||||
// Runs processItem over many cycles across a mix of sources, asserting
|
||||
// goroutines/heap plateau instead of growing unbounded (a leak guard). Skipped under -short.
|
||||
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
||||
if testing.Short() {
|
||||
Skip("skipping soak test in short mode")
|
||||
}
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
repoRoot, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
libRepo := &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
folderRepo := &fakeFolderRepo{result: []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}}
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
prov := &fakeExternalProvider{}
|
||||
artRepo := tests.CreateMockArtworkRepo()
|
||||
albumRepo := tests.CreateMockAlbumRepo()
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}},
|
||||
{ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
ds := &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
store := NewImageStore(GinkgoT().TempDir())
|
||||
deps := &workerDeps{ds: ds, store: store, prov: prov, ffmpeg: ffm}
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
|
||||
// Dangling refs (al/ra ids the repos don't know about) mirror an entity
|
||||
// deleted after being enqueued; ds.Radio auto-provisions an empty mock repo.
|
||||
items := []model.ArtworkQueueItem{
|
||||
{ItemKind: "al", ItemID: "al-folder"},
|
||||
{ItemKind: "al", ItemID: "al-embed"},
|
||||
{ItemKind: "al", ItemID: "al-does-not-exist"},
|
||||
{ItemKind: "ra", ItemID: "ra-does-not-exist"},
|
||||
}
|
||||
|
||||
fdCount := func() int {
|
||||
if runtime.GOOS != "linux" {
|
||||
return -1
|
||||
}
|
||||
entries, err := os.ReadDir("/proc/self/fd")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
|
||||
settleGoroutines := func() int {
|
||||
// Background goroutines (GC workers, etc.) can take a moment to wind down;
|
||||
// poll for two consecutive equal samples instead of trusting a single one.
|
||||
prev := -1
|
||||
for range 100 {
|
||||
runtime.GC()
|
||||
n := runtime.NumGoroutine()
|
||||
if n == prev {
|
||||
return n
|
||||
}
|
||||
prev = n
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
baselineGoroutines := settleGoroutines()
|
||||
baselineFDs := fdCount()
|
||||
|
||||
var heapAt10Pct uint64
|
||||
start := time.Now()
|
||||
for i := range soakCycles {
|
||||
it := items[i%len(items)]
|
||||
out := processItem(context.Background(), deps, it)
|
||||
|
||||
// "Serve-adjacent" read-back: exercise the Phase 2 surfaces a caller would
|
||||
// use after acquisition, not the old serving pipeline.
|
||||
if out == outcomeFound {
|
||||
ia, err := artRepo.GetItemArtwork(it.ItemKind, it.ItemID, model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
||||
art, err := artRepo.GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||
rc, err := store.Open(ia.Hash, art.Mime)
|
||||
switch {
|
||||
case err == nil:
|
||||
_, _ = io.Copy(io.Discard, rc)
|
||||
rc.Close()
|
||||
case os.IsNotExist(err):
|
||||
// Folder-backed art has no store file; that's expected.
|
||||
default:
|
||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i)
|
||||
}
|
||||
}
|
||||
|
||||
if i == soakCycles/10 {
|
||||
runtime.GC()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
heapAt10Pct = ms.HeapAlloc
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
finalGoroutines := settleGoroutines()
|
||||
finalFDs := fdCount()
|
||||
|
||||
runtime.GC()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
|
||||
GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n",
|
||||
soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs)
|
||||
|
||||
Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines)
|
||||
if heapAt10Pct > 0 {
|
||||
Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc)
|
||||
}
|
||||
if runtime.GOOS == "linux" && baselineFDs >= 0 {
|
||||
Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,362 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
// reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the
|
||||
// worker's delete by bumping retry_at, so a DeleteIfUnchanged on the dequeued value no-ops.
|
||||
type reenqueueOnDequeue struct {
|
||||
*tests.MockArtworkQueueRepo
|
||||
done bool
|
||||
}
|
||||
|
||||
func (r *reenqueueOnDequeue) DequeueBatch(n int) ([]model.ArtworkQueueItem, error) {
|
||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(n)
|
||||
if !r.done && len(items) > 0 {
|
||||
r.done = true
|
||||
for k, it := range r.Data {
|
||||
if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID {
|
||||
it.RetryAt = items[0].RetryAt.Add(time.Minute)
|
||||
r.Data[k] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, err
|
||||
}
|
||||
|
||||
func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem {
|
||||
for _, it := range q.Data {
|
||||
if it.ItemKind == kind && it.ItemID == id {
|
||||
return &it
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = Describe("Worker", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *tests.MockDataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
libRepo *tests.MockLibraryRepo
|
||||
ffm *tests.MockFFmpeg
|
||||
prov *fakeExternalProvider
|
||||
store *ImageStore
|
||||
artRepo *tests.MockArtworkRepo
|
||||
queueRepo *tests.MockArtworkQueueRepo
|
||||
repoRoot string
|
||||
w *Worker
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
var err error
|
||||
repoRoot, err = os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
libRepo = &tests.MockLibraryRepo{}
|
||||
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
|
||||
ffm = tests.NewMockFFmpeg("")
|
||||
prov = &fakeExternalProvider{}
|
||||
artRepo = tests.CreateMockArtworkRepo()
|
||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedFolder: folderRepo,
|
||||
MockedLibrary: libRepo,
|
||||
MockedArtwork: artRepo,
|
||||
MockedArtworkQueue: queueRepo,
|
||||
}
|
||||
ds.MockedAlbum = tests.CreateMockAlbumRepo()
|
||||
store = NewImageStore(GinkgoT().TempDir())
|
||||
conf.Server.CoverArtPriority = "cover.jpg, embedded"
|
||||
conf.Server.ArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests
|
||||
w = NewWorker(ds, store, prov, ffm)
|
||||
})
|
||||
|
||||
Describe("drain", func() {
|
||||
It("processes a seeded queue item and removes it from the queue", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||
})
|
||||
|
||||
It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
it := findQueued(queueRepo, "al", "al4")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(it.Attempts).To(Equal(1))
|
||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
|
||||
_, err = artRepo.GetItemArtwork("al", "al4", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
||||
})
|
||||
|
||||
It("reschedules a found-stale item via MarkFailed while keeping its served state", func() {
|
||||
conf.Server.CoverArtPriority = "external, cover.jpg"
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
it := findQueued(queueRepo, "al", "alstale")
|
||||
Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry")
|
||||
Expect(it.Attempts).To(Equal(1))
|
||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||
|
||||
ia, err := artRepo.GetItemArtwork("al", "alstale", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||
})
|
||||
|
||||
It("keeps a row re-enqueued between dequeue and delete", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}}
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
{ID: "al7", Name: "Album", FolderIDs: []string{"f1"}},
|
||||
})
|
||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
w = NewWorker(ds, store, prov, ffm)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
||||
})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork("al", "al7", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
})
|
||||
|
||||
It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}})
|
||||
prov.albumImage = func(context.Context, string) (*url.URL, error) {
|
||||
return nil, errors.New("agent timed out")
|
||||
}
|
||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||
ds.MockedArtworkQueue = racing
|
||||
w = NewWorker(ds, store, prov, ffm)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
// The concurrent re-enqueue reset retry_at; the failure path must not stomp it
|
||||
// with stale backoff nor bump attempts, so the row stays immediately eligible.
|
||||
it := findQueued(queueRepo, "al", "al8")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(it.Attempts).To(BeZero())
|
||||
Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute)))
|
||||
})
|
||||
|
||||
It("resolves a private playlist under an admin context instead of failing forever", func() {
|
||||
ds.MockedUser = adminUserRepo()
|
||||
vds := &visibilityPlaylistDS{
|
||||
MockDataStore: ds,
|
||||
private: model.Playlist{ID: "plPriv", OwnerID: "admin"},
|
||||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
w = NewWorker(vds, store, prov, ffm)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(1))
|
||||
|
||||
// Resolved as absent (no art) and removed — not stuck failing on ErrNotFound forever.
|
||||
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork("pl", "plPriv", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns zero when the queue is empty", func() {
|
||||
n, err := w.drain(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Bump", func() {
|
||||
It("enqueues at Bump priority and wakes the loop", func() {
|
||||
w.Bump("al", "al9")
|
||||
it := findQueued(queueRepo, "al", "al9")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(it.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("gate/breaker", func() {
|
||||
It("opens after 5 consecutive external errors and short-circuits the step", func() {
|
||||
var calls int
|
||||
failing := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
for range 5 {
|
||||
_, _, err := w.gate(failing)
|
||||
Expect(err).To(HaveOccurred())
|
||||
}
|
||||
Expect(calls).To(Equal(5))
|
||||
|
||||
_, _, err := w.gate(failing)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(calls).To(Equal(5), "an open breaker must not call the external step")
|
||||
})
|
||||
|
||||
It("resets the failure count on a successful call", func() {
|
||||
failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") }
|
||||
ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil }
|
||||
for range 4 {
|
||||
_, _, _ = w.gate(failing)
|
||||
}
|
||||
_, _, err := w.gate(ok)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var calls int
|
||||
counting := func() (io.ReadCloser, string, error) {
|
||||
calls++
|
||||
return nil, "", errors.New("boom")
|
||||
}
|
||||
for range 5 {
|
||||
_, _, _ = w.gate(counting)
|
||||
}
|
||||
Expect(calls).To(Equal(5), "the breaker should have re-closed after the success")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RunPrune", func() {
|
||||
It("runs a prune under the worker mutex", func() {
|
||||
Expect(w.RunPrune(ctx)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Run", func() {
|
||||
It("exits cleanly when the context is cancelled", func() {
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(runCtx) }()
|
||||
|
||||
cancel()
|
||||
Eventually(done, time.Second).Should(Receive(BeNil()))
|
||||
})
|
||||
|
||||
It("does not leak goroutines after Run exits", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
ignore := goleak.IgnoreCurrent()
|
||||
DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) })
|
||||
|
||||
localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()}
|
||||
lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg(""))
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- lw.Run(runCtx) }()
|
||||
|
||||
time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("backoff", func() {
|
||||
It("returns the expected schedule with no jitter", func() {
|
||||
for _, c := range []struct {
|
||||
attempts int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 5 * time.Minute},
|
||||
{1, 20 * time.Minute},
|
||||
{2, 80 * time.Minute},
|
||||
{3, 320 * time.Minute},
|
||||
{4, 1280 * time.Minute},
|
||||
{5, 48 * time.Hour},
|
||||
{6, 48 * time.Hour},
|
||||
} {
|
||||
Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts)
|
||||
}
|
||||
})
|
||||
|
||||
It("applies jitter proportionally", func() {
|
||||
base := backoffFor(2, 0)
|
||||
Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2)))
|
||||
Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8)))
|
||||
})
|
||||
|
||||
It("keeps random jitter within +/-20%", func() {
|
||||
lo := time.Duration(float64(320*time.Minute) * 0.8)
|
||||
hi := time.Duration(float64(320*time.Minute) * 1.2)
|
||||
for range 200 {
|
||||
d := backoff(3)
|
||||
Expect(d).To(BeNumerically(">=", lo))
|
||||
Expect(d).To(BeNumerically("<=", hi))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest
|
||||
// needs a *testing.T, which Ginkgo doesn't give.
|
||||
func TestArtworkBreakerHalfOpen(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
b := newBreaker()
|
||||
|
||||
for range breakerThreshold {
|
||||
b.record(errors.New("boom"))
|
||||
}
|
||||
g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors")
|
||||
|
||||
time.Sleep(breakerProbeAfter - time.Nanosecond)
|
||||
g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval")
|
||||
|
||||
time.Sleep(time.Nanosecond)
|
||||
g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted")
|
||||
g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval")
|
||||
|
||||
b.record(errors.New("boom")) // probe fails -> stay open
|
||||
time.Sleep(breakerProbeAfter)
|
||||
g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval")
|
||||
|
||||
b.record(nil) // probe succeeds -> close
|
||||
g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely")
|
||||
g.Expect(b.allow()).To(BeTrue())
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,8 @@ func TestAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
const (
|
||||
oneDay = 24 * time.Hour
|
||||
testJWTSecret = "not so secret"
|
||||
oneDay = 24 * time.Hour
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
|
||||
Vendored
+16
-48
@@ -36,8 +36,6 @@ type Provider interface {
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
||||
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
// ArtistImageResult is like ArtistImage but reports a transient agent failure as a real error, not ErrNotFound.
|
||||
ArtistImageResult(ctx context.Context, id string) (*url.URL, error)
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
}
|
||||
|
||||
@@ -155,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
return album, err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = new(time.Now())
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
@@ -260,7 +258,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
// Call all registered agents and collect information
|
||||
g := errgroup.Group{}
|
||||
g.SetLimit(2)
|
||||
g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
@@ -271,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
@@ -373,35 +371,18 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in
|
||||
}
|
||||
|
||||
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
u, _, err := e.artistImage(ctx, id)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ArtistImageResult is like ArtistImage but surfaces a transient agent failure as the
|
||||
// real error, so an agent outage is not mistaken for a definitive no-image (ErrNotFound).
|
||||
func (e *provider) ArtistImageResult(ctx context.Context, id string) (*url.URL, error) {
|
||||
u, agentErr, err := e.artistImage(ctx, id)
|
||||
if agentErr != nil && errors.Is(err, model.ErrNotFound) {
|
||||
return nil, agentErr
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// artistImage returns the agent error (agentErr) separately from the caller-facing err,
|
||||
// so ArtistImageResult can tell "agent errored" apart from "definitively no image".
|
||||
func (e *provider) artistImage(ctx context.Context, id string) (u *url.URL, agentErr error, err error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
// No cached URL — must fetch from external source synchronously
|
||||
agentErr = e.callGetImage(ctx, e.ag, &artist)
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, agentErr, ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
imageUrl = artist.ArtistImageUrl()
|
||||
} else {
|
||||
@@ -415,10 +396,9 @@ func (e *provider) artistImage(ctx context.Context, id string) (u *url.URL, agen
|
||||
}
|
||||
|
||||
if imageUrl == "" {
|
||||
return nil, agentErr, model.ErrNotFound
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
u, err = url.Parse(imageUrl)
|
||||
return u, agentErr, err
|
||||
return url.Parse(imageUrl)
|
||||
}
|
||||
|
||||
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
@@ -491,19 +471,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
// Enrich top songs with the queried artist. A song with no artists, or whose first credit the
|
||||
// agent left unnamed, is attributed to the queried artist. A first credit that already names an
|
||||
// artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried
|
||||
// MBID onto it would create a false name+MBID pairing.
|
||||
// Enrich songs with artist info if not already present (for top songs, we know the artist)
|
||||
for i := range songs {
|
||||
switch {
|
||||
case len(songs[i].Artists) == 0:
|
||||
songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}}
|
||||
case songs[i].Artists[0].Name == "":
|
||||
songs[i].Artists[0].Name = artistName
|
||||
if songs[i].Artists[0].MBID == "" {
|
||||
songs[i].Artists[0].MBID = artist.MbzArtistID
|
||||
}
|
||||
if songs[i].Artist == "" {
|
||||
songs[i].Artist = artistName
|
||||
}
|
||||
if songs[i].ArtistMBID == "" {
|
||||
songs[i].ArtistMBID = artist.MbzArtistID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,15 +513,10 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
// callGetImage populates artist's image URLs. A transient agent failure is
|
||||
// returned as-is; a definitive "no image" is normalized to model.ErrNotFound.
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) error {
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return err
|
||||
return
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
|
||||
@@ -560,7 +529,6 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
|
||||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
|
||||
+4
-45
@@ -272,11 +272,12 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
|
||||
It("returns cached URL and does not call agent when info is not expired", func() {
|
||||
// Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt
|
||||
recentTime := time.Now().Add(-1 * time.Minute)
|
||||
cachedArtist := &model.Artist{
|
||||
ID: "artist-cached",
|
||||
Name: "Cached Artist",
|
||||
LargeImageUrl: "http://example.com/cached-large.jpg",
|
||||
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
|
||||
ExternalInfoUpdatedAt: &recentTime,
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
|
||||
@@ -303,11 +304,12 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
It("returns stale URL and enqueues refresh when info is expired", func() {
|
||||
// Arrange
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond
|
||||
expiredTime := time.Now().Add(-1 * time.Hour)
|
||||
staleArtist := &model.Artist{
|
||||
ID: "artist-expired",
|
||||
Name: "Expired Artist",
|
||||
LargeImageUrl: "http://example.com/expired-large.jpg",
|
||||
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
|
||||
ExternalInfoUpdatedAt: &expiredTime,
|
||||
}
|
||||
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
|
||||
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")
|
||||
@@ -330,49 +332,6 @@ var _ = Describe("Provider - ArtistImage", func() {
|
||||
Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh"))
|
||||
})
|
||||
|
||||
Describe("ArtistImageResult", func() {
|
||||
It("returns the real agent error on a transient failure, not ErrNotFound", func() {
|
||||
agentErr := errors.New("agent timed out")
|
||||
mockImageAgent.Mock = mock.Mock{}
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once()
|
||||
|
||||
imgURL, err := provider.ArtistImageResult(ctx, "artist-1")
|
||||
|
||||
Expect(err).To(MatchError(agentErr))
|
||||
Expect(err).ToNot(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the agent definitively has no image", func() {
|
||||
mockImageAgent.Mock = mock.Mock{}
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.ArtistImageResult(ctx, "artist-1")
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the agent returns no images without error", func() {
|
||||
mockImageAgent.Mock = mock.Mock{}
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once()
|
||||
|
||||
imgURL, err := provider.ArtistImageResult(ctx, "artist-1")
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the largest image URL on success", func() {
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
imgURL, err := provider.ArtistImageResult(ctx, "artist-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Unicode handling in artist names", func() {
|
||||
var artistWithEnDash *model.Artist
|
||||
var expectedURL *url.URL
|
||||
|
||||
+14
-26
@@ -3,7 +3,6 @@ package external_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
@@ -57,14 +56,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
Context("when ID is a MediaFile (track)", func() {
|
||||
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
|
||||
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
|
||||
|
||||
// Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping.
|
||||
dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"}
|
||||
dmParticipant := model.Participant{Artist: dmArtist}
|
||||
matchedSong := model.MediaFile{
|
||||
ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}},
|
||||
}
|
||||
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
|
||||
|
||||
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
@@ -73,19 +65,16 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
|
||||
{Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
|
||||
}, nil).Once()
|
||||
|
||||
// Matcher artist resolution: resolve Depeche Mode in the artist table.
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe()
|
||||
|
||||
// ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it.
|
||||
// Mock loadTracksByID - no ID matches
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(model.MediaFiles{}, nil).Maybe()
|
||||
})).Return(model.MediaFiles{}, nil).Once()
|
||||
|
||||
// MBID phase: won't fire (empty MBID).
|
||||
// Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
@@ -99,19 +88,18 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
return hasMBID
|
||||
})).Return(model.MediaFiles{}, nil).Maybe()
|
||||
|
||||
// Matcher track-fetch: subquery returns the matched song with participants.
|
||||
// Mock loadTracksByTitleAndArtist - queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok {
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, f := range and {
|
||||
sql, _, err := f.ToSql()
|
||||
if err == nil && strings.Contains(sql, "media_file_artists") {
|
||||
return true
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
@@ -177,7 +165,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
||||
{Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
@@ -254,7 +242,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
|
||||
+7
-80
@@ -76,63 +76,6 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil)
|
||||
|
||||
// Agent leaves the first credit unnamed (e.g. an MBID-less collaborator slot). That blank
|
||||
// credit IS the queried artist, so enrichment fills both name and MBID; the song then matches
|
||||
// the queried artist's track via the backfilled identity.
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", Artists: []agents.Artist{{}}},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
track := model.MediaFile{
|
||||
ID: "song-1", Title: "Song One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{
|
||||
{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}},
|
||||
}},
|
||||
}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil)
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("does not stamp the queried MBID onto an already-named different first credit", func() {
|
||||
// The queried artist (One) appears only as a featured collaborator; the displayed first credit
|
||||
// is a DIFFERENT artist (Two) returned without an MBID. Enrichment must NOT assign One's MBID
|
||||
// to Two — only Two's name match (which fails here) or One's own credit may resolve the track.
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil)
|
||||
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Collab Song", Artists: []agents.Artist{{Name: "Artist Two"}}},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Library track is credited to Artist One (the queried artist) under a same title. If the
|
||||
// queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID
|
||||
// could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track.
|
||||
track := model.MediaFile{
|
||||
ID: "one-track", Title: "Collab Song", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{
|
||||
{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}},
|
||||
}},
|
||||
}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil)
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track.
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns nil for an unknown artist", func() {
|
||||
// Mock artist not found
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
|
||||
@@ -205,8 +148,6 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
// Matcher artist resolution for the title-match path (song2 falls through).
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
@@ -218,7 +159,7 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding matching tracks (only find song 1 on bulk query)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
@@ -254,8 +195,6 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
// Matcher artist resolution for both title-fallback songs.
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// Mock agent response with songs that have NO MBID (empty string)
|
||||
agentSongs := []agents.Song{
|
||||
@@ -264,16 +203,10 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them.
|
||||
participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}}
|
||||
song1 := model.MediaFile{
|
||||
ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}},
|
||||
}
|
||||
song2 := model.MediaFile{
|
||||
ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}},
|
||||
}
|
||||
// Since there are no MBIDs, loadTracksByMBID should not make any database call
|
||||
// loadTracksByTitle should make a database call for title matching
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
@@ -291,8 +224,6 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
// Matcher artist resolution for song2's title-fallback path.
|
||||
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// Mock agent response with mixed MBID availability
|
||||
agentSongs := []agents.Song{
|
||||
@@ -305,12 +236,8 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
// Title track-fetch: song2 must carry RoleArtist participants for back-mapping.
|
||||
participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}}
|
||||
song2 := model.MediaFile{
|
||||
ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1",
|
||||
Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}},
|
||||
}
|
||||
// Mock the title fallback query (finds song2 by title)
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
+3
-2
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -89,7 +90,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
@@ -112,7 +113,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: new(expiredTime),
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
|
||||
+4
-3
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -136,7 +137,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
@@ -173,7 +174,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: new(expiredTime),
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
@@ -204,7 +205,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
|
||||
+10
-93
@@ -7,11 +7,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -68,7 +66,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
@@ -160,80 +158,16 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP
|
||||
return nil, err
|
||||
}
|
||||
if err := fileExists(filePath); err != nil {
|
||||
return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err),
|
||||
NotFound: errors.Is(err, fs.ErrNotExist), err: err}
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
|
||||
log.Trace(ctx, "Executing ffprobe command", "args", args)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err}
|
||||
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
|
||||
}
|
||||
result, err := parseProbeOutput(output)
|
||||
if err != nil {
|
||||
return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProbeError reports an ffprobe failure. Reason is a path-free message safe to
|
||||
// expose to clients; the wrapped cause carries the full detail for logging.
|
||||
// NotFound marks the media file itself as missing — a launch failure of a
|
||||
// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer
|
||||
// it from the error chain.
|
||||
type ProbeError struct {
|
||||
Path string
|
||||
Reason string
|
||||
NotFound bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *ProbeError) Error() string {
|
||||
if e.err == nil {
|
||||
return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err))
|
||||
}
|
||||
|
||||
// Unwrap exposes the underlying cause so callers can test it with errors.Is
|
||||
// (e.g. fs.ErrNotExist to detect a missing file).
|
||||
func (e *ProbeError) Unwrap() error { return e.err }
|
||||
|
||||
// SafeReason returns the path-free reason, safe to send to clients.
|
||||
func (e *ProbeError) SafeReason() string { return e.Reason }
|
||||
|
||||
// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved
|
||||
// or unreadable file reads as "file not found" rather than a raw ffprobe message.
|
||||
func fileAccessReason(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return "file not found"
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return "permission denied"
|
||||
default:
|
||||
return "file not accessible"
|
||||
}
|
||||
}
|
||||
|
||||
// probeDetail returns the full diagnostic for logging (may contain paths):
|
||||
// ffprobe's stderr when present, otherwise the raw error text.
|
||||
func probeDetail(err error) string {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 {
|
||||
return strings.TrimSpace(string(exitErr.Stderr))
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// probeClientReason returns a path-free reason for an ffprobe execution failure:
|
||||
// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe
|
||||
// couldn't run at all (its launch error may embed the binary path).
|
||||
func probeClientReason(err error, path string) string {
|
||||
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||
if !ok || len(exitErr.Stderr) == 0 {
|
||||
return "could not read file"
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file"))
|
||||
return parseProbeOutput(output)
|
||||
}
|
||||
|
||||
type probeOutput struct {
|
||||
@@ -391,7 +325,8 @@ func (j *ffCmd) start(ctx context.Context) error {
|
||||
|
||||
func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())
|
||||
if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" {
|
||||
errMsg += ": " + stderrOutput
|
||||
@@ -459,23 +394,14 @@ func isDefaultCommand(format, command string) bool {
|
||||
// including all transcoding parameters (bitrate, sample rate, channels).
|
||||
func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
args := []string{cmdPath}
|
||||
args := []string{cmdPath, "-i", opts.FilePath}
|
||||
|
||||
if opts.Offset > 0 {
|
||||
args = append(args, "-ss", strconv.Itoa(opts.Offset))
|
||||
}
|
||||
|
||||
args = append(args, "-i", opts.FilePath)
|
||||
args = append(args, "-map", "0:a:0")
|
||||
|
||||
// Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC);
|
||||
// -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG).
|
||||
// Both are needed because the two source families store tags at different
|
||||
// levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids
|
||||
// pulling metadata from an embedded cover-art/video stream at index 0. Note:
|
||||
// adts (AAC) output cannot hold tags, so these are a no-op there.
|
||||
args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0")
|
||||
|
||||
if codec, ok := formatCodecMap[opts.Format]; ok {
|
||||
args = append(args, "-c:a", codec)
|
||||
}
|
||||
@@ -565,20 +491,11 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
var args []string
|
||||
for _, s := range fixCmd(cmd) {
|
||||
if strings.Contains(s, "%s") {
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
// Pre-input seeking: ffmpeg seeks at the demuxer level (fast)
|
||||
// instead of decoding all frames up to the offset (slow).
|
||||
insertAt := len(args)
|
||||
for i, arg := range slices.Backward(args) {
|
||||
if arg == "-i" {
|
||||
insertAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
args = append(args, s)
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
args = append(args, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
|
||||
+10
-87
@@ -2,13 +2,12 @@ package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -48,15 +47,15 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
Context("when command has time offset param", func() {
|
||||
It("creates a valid command line with offset", func() {
|
||||
args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
Context("when command does not have time offset param", func() {
|
||||
It("adds time offset before the input file name", func() {
|
||||
It("adds time offset after the input file name", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -83,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
|
||||
|
||||
Describe("isDefaultCommand", func() {
|
||||
It("returns true for known default mp3 command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default opus command", func() {
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default aac command", func() {
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default flac command", func() {
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
})
|
||||
It("returns false for a custom command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
|
||||
@@ -114,7 +113,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "256k",
|
||||
"-ar", "48000",
|
||||
@@ -134,7 +132,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "flac",
|
||||
"-ar", "48000",
|
||||
"-v", "0",
|
||||
@@ -152,7 +149,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "128k",
|
||||
"-v", "0",
|
||||
@@ -169,11 +165,9 @@ var _ = Describe("ffmpeg", func() {
|
||||
Offset: 30,
|
||||
})
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg",
|
||||
"ffmpeg", "-i", "/music/file.mp3",
|
||||
"-ss", "30",
|
||||
"-i", "/music/file.mp3",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
"-v", "0",
|
||||
@@ -191,7 +185,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k",
|
||||
"-v", "0",
|
||||
@@ -209,7 +202,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-v", "0",
|
||||
@@ -554,65 +546,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ProbeError", func() {
|
||||
It("uses the underlying cause in Error() so logs keep the full detail", func() {
|
||||
e := &ProbeError{Path: "/music/foo.flac",
|
||||
err: errors.New("/music/foo.flac: Invalid data found when processing input")}
|
||||
Expect(e.Error()).To(ContainSubstring("/music/foo.flac"))
|
||||
Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input"))
|
||||
})
|
||||
|
||||
It("returns the path-free reason from SafeReason()", func() {
|
||||
e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"}
|
||||
Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input"))
|
||||
Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac"))
|
||||
})
|
||||
|
||||
It("unwraps to the underlying cause so errors.Is detects a missing file", func() {
|
||||
e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist}
|
||||
Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("probeClientReason", func() {
|
||||
It("strips the file path from ffprobe stderr", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("uses /bin/sh")
|
||||
}
|
||||
_, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found"))
|
||||
})
|
||||
|
||||
It("returns a generic reason for launch failures, without leaking the binary path", func() {
|
||||
err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory")
|
||||
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("probeDetail", func() {
|
||||
It("surfaces ffprobe stderr for logging", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
Skip("uses /bin/sh")
|
||||
}
|
||||
_, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(probeDetail(err)).To(Equal("boom detail"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fileAccessReason", func() {
|
||||
It("reports a missing file as 'file not found', not a raw stat message", func() {
|
||||
_, err := os.Stat("/no/such/dir/really-missing.flac")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(fileAccessReason(err)).To(Equal("file not found"))
|
||||
})
|
||||
|
||||
It("falls back to a generic reason for other access errors", func() {
|
||||
Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
@@ -626,16 +559,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
}
|
||||
})
|
||||
|
||||
It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() {
|
||||
_, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue())
|
||||
var pe *ProbeError
|
||||
Expect(errors.As(err, &pe)).To(BeTrue())
|
||||
Expect(pe.SafeReason()).To(Equal("file not found"))
|
||||
Expect(pe.NotFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should interrupt transcoding when context is cancelled", func() {
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
@@ -20,16 +17,6 @@ type ImageUploadService interface {
|
||||
RemoveImage(ctx context.Context, path string) error
|
||||
}
|
||||
|
||||
// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default
|
||||
// when it's unset/invalid. Shared by every API that accepts image uploads.
|
||||
func MaxImageUploadSize() int64 {
|
||||
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
|
||||
return int64(size)
|
||||
}
|
||||
size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize)
|
||||
return int64(size)
|
||||
}
|
||||
|
||||
type imageUploadService struct{}
|
||||
|
||||
func NewImageUploadService() ImageUploadService {
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||
conf.Server.DataFolder = tmpDir
|
||||
svc = core.NewImageUploadService()
|
||||
})
|
||||
|
||||
@@ -97,29 +97,3 @@ var _ = Describe("ImageUploadService", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MaxImageUploadSize", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("returns the configured size when valid", func() {
|
||||
conf.Server.MaxImageUploadSize = "20MB"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000)))
|
||||
})
|
||||
|
||||
It("returns the default size when config is empty", func() {
|
||||
conf.Server.MaxImageUploadSize = ""
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
})
|
||||
|
||||
It("returns the default size when config is invalid", func() {
|
||||
conf.Server.MaxImageUploadSize = "not-a-size"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
|
||||
})
|
||||
|
||||
It("parses raw byte values", func() {
|
||||
conf.Server.MaxImageUploadSize = "52428800"
|
||||
Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800)))
|
||||
})
|
||||
})
|
||||
+2
-1
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
type InspectOutput struct {
|
||||
@@ -43,7 +44,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
|
||||
result := &InspectOutput{
|
||||
File: filePath,
|
||||
RawTags: tags[file].Tags,
|
||||
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
|
||||
MappedTags: P(md.ToMediaFile(libraryId, folderId)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
+1
-5
@@ -253,11 +253,7 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
||||
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
||||
err = r.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Library(r.ctx).Delete(libID)
|
||||
}, "delete library")
|
||||
err = r.LibraryRepository.Delete(libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
+19
-85
@@ -4,122 +4,56 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
)
|
||||
|
||||
// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy
|
||||
// artist/title lookup, so source-priority resolution can still reach older
|
||||
// matches without turning it into an unbounded table scan.
|
||||
const maxLegacyLyricsCandidates = 10
|
||||
|
||||
// Provider fetches lyrics for a single media file. It is the contract
|
||||
// implemented by individual lyrics sources, such as plugins.
|
||||
type Provider interface {
|
||||
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||
}
|
||||
|
||||
// Lyrics resolves lyrics for media files, honoring the configured source
|
||||
// priority.
|
||||
// Lyrics can fetch lyrics for a media file.
|
||||
type Lyrics interface {
|
||||
Provider
|
||||
GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
|
||||
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||
}
|
||||
|
||||
// PluginLoader discovers and loads lyrics provider plugins.
|
||||
type PluginLoader interface {
|
||||
LoadLyricsProvider(name string) (Provider, bool)
|
||||
LoadLyricsProvider(name string) (Lyrics, bool)
|
||||
}
|
||||
|
||||
type lyricsService struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
}
|
||||
|
||||
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
|
||||
// system is available.
|
||||
func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{ds: ds, pluginLoader: pluginLoader}
|
||||
func NewLyrics(pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{pluginLoader: pluginLoader}
|
||||
}
|
||||
|
||||
// GetLyrics returns lyrics for the given media file, trying sources in the
|
||||
// order specified by conf.Server.LyricsPriority.
|
||||
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
|
||||
}
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup,
|
||||
// scanning a bounded window of duplicate matches so source priority still wins
|
||||
// across them.
|
||||
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
|
||||
opts := songsByArtistTitleWithLyricsFirst(artist, title)
|
||||
opts.Max = maxLegacyLyricsCandidates
|
||||
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(mediaFiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
candidates := make([]*model.MediaFile, 0, len(mediaFiles))
|
||||
for i := range mediaFiles {
|
||||
candidates = append(candidates, &mediaFiles[i])
|
||||
}
|
||||
return l.getLyricsForCandidates(ctx, candidates)
|
||||
}
|
||||
|
||||
func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions {
|
||||
return model.QueryOptions{
|
||||
Sort: "lyrics, updated_at",
|
||||
Order: "desc",
|
||||
Filters: And{
|
||||
Eq{"missing": false},
|
||||
Eq{"title": title},
|
||||
Or{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
|
||||
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
switch {
|
||||
case strings.EqualFold(pattern, "embedded"):
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
|
||||
default:
|
||||
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
|
||||
}
|
||||
|
||||
for _, mf := range mediaFiles {
|
||||
if mf == nil {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
|
||||
switch {
|
||||
case strings.EqualFold(pattern, "embedded"):
|
||||
return fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
return fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
return l.fromPlugin(ctx, mf, pattern)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -19,17 +15,3 @@ func TestLyrics(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Lyrics Suite")
|
||||
}
|
||||
|
||||
// core/storage/local calls log.Fatal if the default scanner extractor is unregistered
|
||||
// when constructing any localStorage. Register a no-op so storage.For("file://...") works
|
||||
// in tests without importing the real extractor.
|
||||
var _ = BeforeSuite(func() {
|
||||
local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor {
|
||||
return &noopExtractor{}
|
||||
})
|
||||
})
|
||||
|
||||
type noopExtractor struct{}
|
||||
|
||||
func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil }
|
||||
func (e *noopExtractor) Version() string { return "noop" }
|
||||
+20
-239
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -13,23 +12,18 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Lyrics", func() {
|
||||
var _ = Describe("sources", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
embeddedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{Value: "This is a set of lyrics"},
|
||||
{Value: "That is not good"},
|
||||
},
|
||||
},
|
||||
}
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
@@ -38,80 +32,15 @@ var _ = Describe("Lyrics", func() {
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: new(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
elrcLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "ELRC Artist",
|
||||
DisplayTitle: "ELRC Song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(1000)),
|
||||
End: new(int64(3000)),
|
||||
Value: "Lead words",
|
||||
Cue: []model.Cue{
|
||||
{
|
||||
Start: new(int64(1000)),
|
||||
End: new(int64(1500)),
|
||||
Value: "Lead ",
|
||||
ByteStart: 0,
|
||||
ByteEnd: 4,
|
||||
},
|
||||
{
|
||||
Start: new(int64(1500)),
|
||||
End: new(int64(3000)),
|
||||
Value: "words",
|
||||
ByteStart: 5,
|
||||
ByteEnd: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Start: new(int64(3000)),
|
||||
Value: "Fallback line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
ttmlLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Kind: "main",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22800)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
model.Lyrics{
|
||||
Kind: "main",
|
||||
Lang: "por",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
Value: "Nao somos estranhos ao amor",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
@@ -131,25 +60,6 @@ var _ = Describe("Lyrics", func() {
|
||||
},
|
||||
}
|
||||
|
||||
srtLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: new(int64(18800)),
|
||||
End: new(int64(22800)),
|
||||
Value: "We're from subtitles",
|
||||
},
|
||||
{
|
||||
Start: new(int64(22801)),
|
||||
End: new(int64(26000)),
|
||||
Value: "Another subtitle line",
|
||||
},
|
||||
},
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
@@ -159,104 +69,19 @@ var _ = Describe("Lyrics", func() {
|
||||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = GinkgoT().Context()
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
|
||||
|
||||
It("resolves source priority across duplicate media files", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
embeddedJSON, err := json.Marshal(embeddedLyrics)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
repo := &tests.MockMediaFileRepo{}
|
||||
repo.SetData(model.MediaFiles{
|
||||
{
|
||||
Lyrics: string(embeddedJSON),
|
||||
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
|
||||
},
|
||||
{
|
||||
Lyrics: "[]",
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
},
|
||||
})
|
||||
svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil)
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(ttmlLyrics))
|
||||
})
|
||||
|
||||
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
|
||||
dir, err := os.MkdirTemp("", "lyrics-case-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
Expect(os.RemoveAll(dir)).To(Succeed())
|
||||
})
|
||||
|
||||
probe := filepath.Join(dir, "CASECHECK")
|
||||
Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed())
|
||||
_, err = os.Stat(filepath.Join(dir, "casecheck"))
|
||||
if err == nil {
|
||||
Skip("filesystem is case-insensitive")
|
||||
}
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
conf.Server.LyricsPriority = ".LRC"
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed())
|
||||
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &model.MediaFile{
|
||||
LibraryPath: dir,
|
||||
Path: "song.mp3",
|
||||
})
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line).To(Equal([]model.Line{
|
||||
{Start: new(int64(1000)), Value: "Upper suffix"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() {
|
||||
dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
Expect(os.RemoveAll(dir)).To(Succeed())
|
||||
})
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed())
|
||||
|
||||
conf.Server.LyricsPriority = ".yaml,.lrc"
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &model.MediaFile{
|
||||
LibraryPath: dir,
|
||||
Path: "song.mp3",
|
||||
})
|
||||
|
||||
// ParseLyrics falls back to plain text for any suffix when the content
|
||||
// doesn't match the structured format, so the .yaml hit is non-empty and
|
||||
// shadows the lower-priority .lrc entirely.
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Synced).To(BeFalse())
|
||||
Expect(list[0].Line).To(Equal([]model.Line{
|
||||
{Value: "title: not lyricsfile"},
|
||||
}))
|
||||
})
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
@@ -286,7 +111,7 @@ var _ = Describe("Lyrics", func() {
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
@@ -295,7 +120,7 @@ var _ = Describe("Lyrics", func() {
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
@@ -313,7 +138,7 @@ var _ = Describe("Lyrics", func() {
|
||||
It("should return lyrics from a plugin", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
@@ -323,7 +148,7 @@ var _ = Describe("Lyrics", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mf.Lyrics = "" // No embedded lyrics
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
@@ -332,7 +157,7 @@ var _ = Describe("Lyrics", func() {
|
||||
It("should skip plugin if embedded has lyrics", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
@@ -341,7 +166,7 @@ var _ = Describe("Lyrics", func() {
|
||||
It("should skip unknown plugin names gracefully", func() {
|
||||
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
|
||||
mockLoader.notFound = true
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
@@ -351,7 +176,7 @@ var _ = Describe("Lyrics", func() {
|
||||
conf.Server.LyricsPriority = "MyLyricsPlugin"
|
||||
mockLoader.pluginName = "MyLyricsPlugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
@@ -360,56 +185,12 @@ var _ = Describe("Lyrics", func() {
|
||||
It("should handle plugin error gracefully", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||
mockLoader.err = fmt.Errorf("plugin error")
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("GetLyricsByArtistTitle", func() {
|
||||
var svc lyrics.Lyrics
|
||||
var repo *tests.MockMediaFileRepo
|
||||
var ds *tests.MockDataStore
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded"
|
||||
repo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: repo}
|
||||
svc = lyrics.NewLyrics(ds, nil)
|
||||
})
|
||||
|
||||
It("bounds the query to a duplicate window", func() {
|
||||
repo.SetData(model.MediaFiles{})
|
||||
_, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(repo.Options.Max).To(Equal(10))
|
||||
})
|
||||
|
||||
It("returns nil when no media file matches", func() {
|
||||
repo.SetData(model.MediaFiles{})
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(BeNil())
|
||||
})
|
||||
|
||||
It("resolves lyrics from the matched media files", func() {
|
||||
embeddedList, err := model.ParseLyrics(ctx, ".lrc", "eng", []byte("Embedded lyrics line"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
embedded, _ := embeddedList.Main()
|
||||
embeddedJSON, err := json.Marshal(model.LyricList{embedded})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
repo.SetData(model.MediaFiles{
|
||||
{ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)},
|
||||
})
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPluginLoader struct {
|
||||
@@ -426,7 +207,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string {
|
||||
return []string{"test-lyrics-plugin"}
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
if m.notFound {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
+14
-32
@@ -3,12 +3,9 @@ package lyrics
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
@@ -26,46 +23,31 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
ext := path.Ext(mf.Path)
|
||||
sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix
|
||||
ctx = log.NewContext(ctx, "file", sidecarRelPath)
|
||||
basePath := mf.AbsolutePath()
|
||||
ext := path.Ext(basePath)
|
||||
|
||||
store, err := storage.For(mf.LibraryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting storage for library: %w", err)
|
||||
}
|
||||
fsys, err := store.FS()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening library filesystem: %w", err)
|
||||
}
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
|
||||
f, err := fsys.Open(sidecarRelPath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path")
|
||||
contents, err := ioutils.UTF8ReadFile(externalLyric)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
contents, err := io.ReadAll(ioutils.UTF8Reader(f))
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list, err := model.ParseLyrics(ctx, suffix, "xxx", contents)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing external lyric file", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
log.Trace(ctx, "empty lyrics from external file")
|
||||
} else if lyrics == nil {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file")
|
||||
return list, nil
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
|
||||
+70
-77
@@ -3,19 +3,15 @@ package lyrics
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
Describe("fromEmbedded", func() {
|
||||
It("should return nothing for a media file with no lyrics", func() {
|
||||
@@ -30,12 +26,10 @@ var _ = Describe("sources", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
|
||||
expectedList := model.LyricList{synced, unsynced}
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -60,94 +54,93 @@ var _ = Describe("sources", func() {
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
var fixturesDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
// tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly.
|
||||
abs, err := filepath.Abs("tests/fixtures")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fixturesDir = abs
|
||||
})
|
||||
|
||||
mf := func(name string) *model.MediaFile {
|
||||
return &model.MediaFile{LibraryPath: fixturesDir, Path: name}
|
||||
}
|
||||
|
||||
It("should return nil for lyrics that don't exist", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc")
|
||||
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
// fromExternalFile delegates format parsing to model.ParseLyrics; the
|
||||
// per-format parser output is covered exhaustively in the model package.
|
||||
// Here we only verify each suffix is read from the library FS and routed.
|
||||
DescribeTable("should read the sidecar file and route its suffix to a parser",
|
||||
func(name, suffix string, expectSynced bool) {
|
||||
lyrics, err := fromExternalFile(ctx, mf(name), suffix)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Line).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Synced).To(Equal(expectSynced))
|
||||
},
|
||||
Entry(".lrc synced", "test.mp3", ".lrc", true),
|
||||
Entry(".elrc enhanced", "test.mp3", ".elrc", true),
|
||||
Entry(".txt plain", "test.mp3", ".txt", false),
|
||||
Entry(".srt subtitles", "test.mp3", ".srt", true),
|
||||
Entry(".ttml multilingual", "test.mp3", ".ttml", true),
|
||||
Entry(".yaml lyricsfile", "test.mp3", ".yaml", true),
|
||||
)
|
||||
|
||||
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc")
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
|
||||
// The function looks for <basePath-without-ext><suffix>, so we need to pass
|
||||
// a MediaFile with .mp3 path and look for .lrc suffix
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
// The critical assertion: even with BOM, synced should be true
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
|
||||
})
|
||||
|
||||
It("should handle UTF-16 LE encoded LRC files", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc")
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
// UTF-16 should be properly converted to UTF-8
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
})
|
||||
|
||||
It("should handle TTML files with UTF-8 BOM marker", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
Expect(lyrics[0].Kind).To(Equal("main"))
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line"))
|
||||
})
|
||||
|
||||
It("should handle UTF-16 BE encoded TTML files", func() {
|
||||
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
Expect(lyrics[0].Kind).To(Equal("main"))
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
// Package matcher matches song results from external agents (Last.fm, Deezer,
|
||||
// etc.) to tracks in the local music library, prioritizing accuracy over recall.
|
||||
//
|
||||
// It exposes a single [Matcher] type with two entry points that share the same
|
||||
// matching algorithm:
|
||||
//
|
||||
// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library
|
||||
// tracks, capped at a requested count. Use it when presenting "similar
|
||||
// songs" results to a client.
|
||||
// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched
|
||||
// track, with no deduplication. Use it when the caller needs to correlate
|
||||
// each result back to its input position (e.g. to attach a per-song
|
||||
// similarity score).
|
||||
//
|
||||
// # Algorithm Overview
|
||||
//
|
||||
// Each input song is resolved to its best-matching library track using four
|
||||
// strategies, applied in priority order. A song matched by a higher-priority
|
||||
// strategy is never reconsidered by a lower-priority one:
|
||||
//
|
||||
// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID.
|
||||
// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to
|
||||
// tracks with the same mbz_recording_id.
|
||||
// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag.
|
||||
// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string
|
||||
// comparison with metadata-specificity scoring (see below).
|
||||
//
|
||||
// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable
|
||||
// identifiers always take precedence over fuzzy text matching. Missing tracks
|
||||
// (those no longer present on disk) are never matched.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// Title+artist matching uses Jaro-Winkler similarity, with a threshold
|
||||
// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library
|
||||
// track must clear the title threshold to be considered. Candidates that clear
|
||||
// it are ranked by, in order:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0–1.0)
|
||||
// 2. Duration proximity (closer duration scores higher; 1.0 when the agent
|
||||
// reports no duration)
|
||||
// 3. Specificity level (0–5, based on metadata precision; higher is better)
|
||||
// 4. Artist overlap (how many of the song's artists the track credits; more
|
||||
// shared artists is better)
|
||||
// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred;
|
||||
// prioritizes tracks that are starred or rated >= 4, but only among
|
||||
// candidates of equal specificity and overlap)
|
||||
// 6. Album similarity (Jaro-Winkler, as the final tiebreaker)
|
||||
//
|
||||
// The specificity levels, from most to least specific, are:
|
||||
//
|
||||
// Level 5: Title + Artist identity + Album MBID
|
||||
// Level 4: Title + Artist identity + Album name (fuzzy)
|
||||
// Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// Level 2: Title + Artist identity
|
||||
// Level 1: Title + Artist name
|
||||
// Level 0: Title only
|
||||
//
|
||||
// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal,
|
||||
// when a source supplies one) or its MBID. A plain name match is the weaker fallback
|
||||
// used for an artist with no identity match (e.g. a cover credited to a different
|
||||
// artist of the same name).
|
||||
//
|
||||
// The title phase always requires an agent artist to scope the library query, so
|
||||
// Level 0 does not mean "no artist": it applies when a candidate matches on title
|
||||
// but its own artist differs from the query's (e.g. a cover or a featured-artist
|
||||
// credit), leaving the title as the only shared field.
|
||||
//
|
||||
// A song may carry several artists, and the title phase scopes candidate tracks by
|
||||
// ANY of them: a track credited to at least one shared artist is considered. When a
|
||||
// source supplies a Navidrome artist ID, that artist is matched directly, skipping
|
||||
// name/MBID resolution. Among equally specific candidates, the one sharing more of
|
||||
// the song's artists wins, so a track crediting every collaborator outranks one
|
||||
// crediting only a single artist.
|
||||
//
|
||||
// Each input song is scored independently, so two songs with the same title and
|
||||
// artist but different durations can resolve to different library tracks (each
|
||||
// matches the track closest to its own duration).
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// All examples below exercise the title+artist phase, where the interesting
|
||||
// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that
|
||||
// always win over fuzzy matching; they need no illustration.)
|
||||
//
|
||||
// Title threshold — a near-miss title still matches; an exact-only threshold
|
||||
// rejects it:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
// With threshold 85%: match succeeds (similarity ~0.87)
|
||||
// With threshold 100%: no match (not an exact title)
|
||||
//
|
||||
// Specificity ranking — among candidates that clear the title threshold, a
|
||||
// better album match wins:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has:
|
||||
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1
|
||||
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3
|
||||
// Result: t2 (Level 3 beats Level 1 on the album match)
|
||||
//
|
||||
// Duration tiebreak — with title and artist equal, the closest duration wins,
|
||||
// so two near-identical input songs can resolve to different tracks:
|
||||
//
|
||||
// Agent returns:
|
||||
// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05
|
||||
// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take)
|
||||
// Library has:
|
||||
// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08
|
||||
// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02
|
||||
// Result: studio for the first song, live for the second
|
||||
//
|
||||
// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a
|
||||
// starred (or rating >= 4) track is preferred, but only when specificity and
|
||||
// artist overlap are equal. A more specific match always wins regardless of the
|
||||
// preferred flag:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has:
|
||||
// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3
|
||||
// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred
|
||||
// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity)
|
||||
package matcher
|
||||
+293
-413
@@ -3,16 +3,12 @@ package matcher
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
@@ -27,73 +23,176 @@ func New(ds model.DataStore) *Matcher {
|
||||
return &Matcher{ds: ds}
|
||||
}
|
||||
|
||||
// MatchSongs matches agent songs to library tracks and returns up to count
|
||||
// tracks in the input's order. See the package documentation for the matching
|
||||
// algorithm.
|
||||
// MatchSongs matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
// Each library track appears at most once, unless the same input song is
|
||||
// repeated: identical input songs intentionally yield repeated output tracks,
|
||||
// while distinct songs that resolve to the same track are deduplicated. Songs
|
||||
// that cannot be matched are skipped.
|
||||
// # Algorithm Overview
|
||||
//
|
||||
// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
|
||||
// local music library using four matching strategies in priority order:
|
||||
//
|
||||
// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
|
||||
// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
|
||||
// matching mbz_recording_id
|
||||
// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag
|
||||
// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
|
||||
// with metadata specificity scoring
|
||||
//
|
||||
// # Matching Priority
|
||||
//
|
||||
// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist.
|
||||
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
||||
// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is
|
||||
// starred or has rating >= 4)
|
||||
// 4. Specificity level (0-5, based on metadata precision):
|
||||
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// - Level 2: Title + Artist MBID
|
||||
// - Level 1: Title + Artist name
|
||||
// - Level 0: Title only
|
||||
// 5. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// Example 1 - MBID Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (MBID match takes priority over title+artist)
|
||||
//
|
||||
// Example 2 - ISRC Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (ISRC match takes priority over title+artist)
|
||||
//
|
||||
// Example 3 - Specificity Ranking:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
|
||||
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
|
||||
// ]
|
||||
// Result: t2 (Level 3 beats Level 1 due to album match)
|
||||
//
|
||||
// Example 4 - Fuzzy Title Matching:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
// With threshold=85%: Match succeeds (similarity ~0.87)
|
||||
// With threshold=100%: No match (not exact)
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - ctx: Context for database operations
|
||||
// - songs: Slice of agent.Song results from external providers
|
||||
// - count: Maximum number of matches to return
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// Returns up to 'count' MediaFiles from the library that best match the input songs,
|
||||
// preserving the original order from the agent. Songs that cannot be matched are skipped.
|
||||
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
matches, err := m.resolveMatches(ctx, songs)
|
||||
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return orderAndDedup(songs, matches, count), nil
|
||||
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
|
||||
}
|
||||
|
||||
// MatchSongsIndexed matches agent songs to library tracks and returns a map from
|
||||
// input-song index to matched track, letting callers correlate results back to
|
||||
// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs,
|
||||
// results are not deduplicated. See the package documentation for the matching
|
||||
// algorithm.
|
||||
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
|
||||
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
|
||||
// This preserves original indices, allowing callers to correlate results back to the input slice.
|
||||
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
|
||||
if len(songs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return m.resolveMatches(ctx, songs)
|
||||
}
|
||||
|
||||
// resolveMatches resolves each input song to its best-matching library track,
|
||||
// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC >
|
||||
// Title); each only fills indices not already matched by a higher-priority loader.
|
||||
func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
|
||||
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[int]model.MediaFile, len(songs))
|
||||
if err := m.matchByID(ctx, songs, result); err != nil {
|
||||
return nil, fmt.Errorf("failed to match tracks by ID: %w", err)
|
||||
}
|
||||
if err := m.matchByMBID(ctx, songs, result); err != nil {
|
||||
return nil, fmt.Errorf("failed to match tracks by MBID: %w", err)
|
||||
}
|
||||
if err := m.matchByISRC(ctx, songs, result); err != nil {
|
||||
return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err)
|
||||
}
|
||||
// The title phase is best-effort: a DB failure there must not discard the exact
|
||||
// matches already found by the higher-priority phases. Only surface it as fatal
|
||||
// when nothing matched at all.
|
||||
if err := m.matchByTitle(ctx, songs, result); err != nil {
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("failed to match tracks by title: %w", err)
|
||||
for i, t := range songs {
|
||||
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
|
||||
result[i] = mf
|
||||
}
|
||||
log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// matchByID fills result with direct ID matches.
|
||||
func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
|
||||
byID, err = m.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
return byID, byMBID, byISRC, byTitle, nil
|
||||
}
|
||||
|
||||
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
|
||||
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
|
||||
_, found := lookupByIdentifiers(s, priorMatches...)
|
||||
return found
|
||||
}
|
||||
|
||||
// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
|
||||
func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
keys := []string{s.ID, s.MBID, s.ISRC}
|
||||
for _, m := range maps {
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
if mf, ok := m[key]; ok && mf.ID != "" {
|
||||
return mf, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
|
||||
func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
return matches, nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@@ -102,37 +201,27 @@ func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return matches, err
|
||||
}
|
||||
byID := make(map[string]model.MediaFile, len(res))
|
||||
for _, mf := range res {
|
||||
byID[mf.ID] = mf // media_file.id is unique, so no dedup needed
|
||||
}
|
||||
for i, s := range songs {
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
if mf, ok := byID[s.ID]; ok {
|
||||
result[i] = mf
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// matchByMBID fills result with MusicBrainz Recording ID matches, skipping
|
||||
// songs already matched by a higher-priority loader.
|
||||
func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
|
||||
func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" {
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return nil
|
||||
return matches, nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@@ -141,86 +230,52 @@ func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result m
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return matches, err
|
||||
}
|
||||
byMBID := make(map[string]model.MediaFile, len(res))
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := byMBID[id]; !ok {
|
||||
byMBID[id] = mf
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.MBID == "" {
|
||||
continue
|
||||
}
|
||||
if mf, ok := byMBID[s.MBID]; ok {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// matchByISRC fills result with ISRC tag matches, skipping songs already
|
||||
// matched by a higher-priority loader.
|
||||
func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
// loadTracksByISRC fetches MediaFiles from the library using ISRC matching.
|
||||
func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var isrcs []string
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.ISRC != "" {
|
||||
for _, s := range songs {
|
||||
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
|
||||
isrcs = append(isrcs, s.ISRC)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(isrcs) == 0 {
|
||||
return nil
|
||||
return matches, nil
|
||||
}
|
||||
res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": false},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return matches, err
|
||||
}
|
||||
byISRC := make(map[string]model.MediaFile, len(res))
|
||||
for _, mf := range res {
|
||||
for _, isrc := range mf.Tags.Values(model.TagISRC) {
|
||||
if _, ok := byISRC[isrc]; !ok {
|
||||
byISRC[isrc] = mf
|
||||
if _, ok := matches[isrc]; !ok {
|
||||
matches[isrc] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
if s.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if mf, ok := byISRC[s.ISRC]; ok {
|
||||
result[i] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID
|
||||
// resolution; name is pre-sanitized (article-stripped).
|
||||
type queryArtist struct {
|
||||
id string
|
||||
name string
|
||||
mbid string
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// songQuery represents a normalized query for matching a song to library tracks.
|
||||
type songQuery struct {
|
||||
title string
|
||||
artists []queryArtist
|
||||
artist string
|
||||
artistMBID string
|
||||
album string
|
||||
albumMBID string
|
||||
durationMs uint32
|
||||
@@ -231,13 +286,11 @@ type matchScore struct {
|
||||
titleSimilarity float64
|
||||
durationProximity float64
|
||||
preferredMatch bool
|
||||
specificityLevel int
|
||||
artistOverlap int
|
||||
albumSimilarity float64
|
||||
specificityLevel int
|
||||
}
|
||||
|
||||
// betterThan returns true if this score beats another.
|
||||
// Identity signals (specificity, overlap) outrank the taste signal (preferred).
|
||||
func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.titleSimilarity != other.titleSimilarity {
|
||||
return s.titleSimilarity > other.titleSimilarity
|
||||
@@ -245,313 +298,106 @@ func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.durationProximity != other.durationProximity {
|
||||
return s.durationProximity > other.durationProximity
|
||||
}
|
||||
if s.specificityLevel != other.specificityLevel {
|
||||
return s.specificityLevel > other.specificityLevel
|
||||
}
|
||||
if s.artistOverlap != other.artistOverlap {
|
||||
return s.artistOverlap > other.artistOverlap
|
||||
}
|
||||
if s.preferredMatch != other.preferredMatch {
|
||||
return s.preferredMatch
|
||||
}
|
||||
if s.specificityLevel != other.specificityLevel {
|
||||
return s.specificityLevel > other.specificityLevel
|
||||
}
|
||||
return s.albumSimilarity > other.albumSimilarity
|
||||
}
|
||||
|
||||
// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization
|
||||
// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid
|
||||
// copying the large MediaFile struct into each entry of the sanitized slice.
|
||||
// when the same track is scored against multiple queries in the inner loop. The `mf` field
|
||||
// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist
|
||||
// sanitized slice.
|
||||
type sanitizedTrack struct {
|
||||
mf *model.MediaFile
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal
|
||||
artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path)
|
||||
mf *model.MediaFile
|
||||
title string
|
||||
artist string
|
||||
album string
|
||||
}
|
||||
|
||||
func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack {
|
||||
func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
|
||||
return sanitizedTrack{
|
||||
mf: mf,
|
||||
title: str.SanitizeFieldForSorting(mf.Title),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
|
||||
album: str.SanitizeFieldForSorting(mf.Album),
|
||||
artistIDs: artistIDs,
|
||||
artistMBIDs: artistMBIDs,
|
||||
mf: mf,
|
||||
title: str.SanitizeFieldForSorting(mf.Title),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
|
||||
album: str.SanitizeFieldForSorting(mf.Album),
|
||||
}
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best
|
||||
// level achievable across any of the query's artists. Fields must be pre-sanitized.
|
||||
//
|
||||
// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the
|
||||
// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers.
|
||||
// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited
|
||||
// to a different artist by the same name).
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// The track's title, artist, and album fields must be pre-sanitized.
|
||||
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
|
||||
best := 0
|
||||
albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold
|
||||
for _, a := range q.artists {
|
||||
_, idMember := t.artistIDs[a.id]
|
||||
_, mbidMember := t.artistMBIDs[a.mbid]
|
||||
identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember)
|
||||
level := 0
|
||||
switch {
|
||||
case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID:
|
||||
level = 5
|
||||
case identity && q.album != "" && albumOK:
|
||||
level = 4
|
||||
case a.name != "" && q.album != "" && t.artist == a.name && albumOK:
|
||||
level = 3
|
||||
case identity:
|
||||
level = 2
|
||||
case a.name != "" && t.artist == a.name:
|
||||
level = 1
|
||||
}
|
||||
if level > best {
|
||||
best = level
|
||||
}
|
||||
if q.artistMBID != "" && q.albumMBID != "" &&
|
||||
t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
|
||||
return 5
|
||||
}
|
||||
return best
|
||||
if q.artistMBID != "" && q.album != "" &&
|
||||
t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 4
|
||||
}
|
||||
if q.artist != "" && q.album != "" &&
|
||||
t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold {
|
||||
return 3
|
||||
}
|
||||
if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID {
|
||||
return 2
|
||||
}
|
||||
if q.artist != "" && t.artist == q.artist {
|
||||
return 1
|
||||
}
|
||||
if t.title == q.title {
|
||||
return 0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// indexedQuery pairs a normalized songQuery with the index of the input song
|
||||
// it came from, so title matches can be written back to result by index.
|
||||
type indexedQuery struct {
|
||||
index int
|
||||
query songQuery
|
||||
}
|
||||
|
||||
// matchByTitle fills result with fuzzy title+artist matches, skipping songs
|
||||
// already matched by a higher-priority loader.
|
||||
func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error {
|
||||
queries := groupQueries(songs, result)
|
||||
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
|
||||
func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := m.buildTitleQueries(songs, priorMatches...)
|
||||
if len(queries) == 0 {
|
||||
return nil
|
||||
return map[string]model.MediaFile{}, nil
|
||||
}
|
||||
|
||||
resolved, err := m.resolveArtists(ctx, queries)
|
||||
if err != nil || len(resolved.allIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracksByQuery := resolved.bucketTracks(tracks)
|
||||
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
|
||||
for _, iq := range queries {
|
||||
sanitized := tracksByQuery[iq.index]
|
||||
if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found {
|
||||
result[iq.index] = mf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupQueries builds one normalized title query per still-unmatched song, carrying its full
|
||||
// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name;
|
||||
// songs with no usable artist are skipped (the title phase needs at least one to scope the query).
|
||||
func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery {
|
||||
var queries []indexedQuery
|
||||
for i, s := range songs {
|
||||
if _, done := result[i]; done {
|
||||
continue
|
||||
}
|
||||
var artists []queryArtist
|
||||
for _, a := range s.Artists {
|
||||
name := str.SanitizeFieldForSortingNoArticle(a.Name)
|
||||
if a.ID == "" && name == "" && a.MBID == "" {
|
||||
continue
|
||||
}
|
||||
artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID})
|
||||
}
|
||||
if len(artists) == 0 {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, indexedQuery{index: i, query: songQuery{
|
||||
title: str.SanitizeFieldForSorting(s.Name),
|
||||
artists: artists,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
durationMs: s.Duration,
|
||||
}})
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index
|
||||
// that owns them. Routing is always by stable artist ID, never by name.
|
||||
type resolvedArtists struct {
|
||||
byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs
|
||||
mbid map[string]string // artist ID -> its MBID (from the artist table)
|
||||
allIDs []string // every resolved artist ID, for the track lookup
|
||||
}
|
||||
|
||||
// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a
|
||||
// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in
|
||||
// one batched query. Ownership is recorded per query index.
|
||||
func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) {
|
||||
res := resolvedArtists{
|
||||
byQuery: make(map[int]map[string]struct{}, len(queries)),
|
||||
mbid: make(map[string]string),
|
||||
}
|
||||
allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved
|
||||
|
||||
// One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists
|
||||
// take the fast-path and are owned directly.
|
||||
type pendingArtist struct {
|
||||
name, mbid string
|
||||
query int
|
||||
}
|
||||
var pending []pendingArtist
|
||||
for _, iq := range queries {
|
||||
for _, a := range iq.query.artists {
|
||||
if a.id != "" {
|
||||
addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly
|
||||
allIDs[a.id] = struct{}{}
|
||||
continue
|
||||
}
|
||||
pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index})
|
||||
}
|
||||
}
|
||||
// query indices that supplied each order name / each MBID (skip the empty key — an artist may
|
||||
// have only one of name/mbid).
|
||||
nameToQueries := map[string][]int{}
|
||||
mbidToQueries := map[string][]int{}
|
||||
for _, p := range pending {
|
||||
if p.name != "" {
|
||||
nameToQueries[p.name] = append(nameToQueries[p.name], p.query)
|
||||
}
|
||||
if p.mbid != "" {
|
||||
mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query)
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
if q.artist != "" {
|
||||
byArtist[q.artist] = append(byArtist[q.artist], q)
|
||||
}
|
||||
}
|
||||
|
||||
// Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are
|
||||
// available for specificity scoring).
|
||||
var filter squirrel.Or
|
||||
if len(nameToQueries) > 0 {
|
||||
filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))})
|
||||
}
|
||||
if len(mbidToQueries) > 0 {
|
||||
filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))})
|
||||
}
|
||||
if len(allIDs) > 0 {
|
||||
filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))})
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter})
|
||||
matches := map[string]model.MediaFile{}
|
||||
for artist, artistQueries := range byArtist {
|
||||
tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"order_artist_name": artist},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
return resolvedArtists{}, err
|
||||
continue
|
||||
}
|
||||
for _, a := range artists {
|
||||
res.mbid[a.ID] = a.MbzArtistID
|
||||
allIDs[a.ID] = struct{}{}
|
||||
for _, idx := range nameToQueries[a.OrderArtistName] {
|
||||
addToSet(res.byQuery, idx, a.ID)
|
||||
}
|
||||
if a.MbzArtistID != "" {
|
||||
for _, idx := range mbidToQueries[a.MbzArtistID] {
|
||||
addToSet(res.byQuery, idx, a.ID)
|
||||
|
||||
sanitized := make([]sanitizedTrack, len(tracks))
|
||||
for i := range tracks {
|
||||
sanitized[i] = newSanitizedTrack(&tracks[i])
|
||||
}
|
||||
|
||||
for _, q := range artistQueries {
|
||||
if mf, found := m.findBestMatch(q, sanitized, threshold); found {
|
||||
key := q.title + "|" + q.artist
|
||||
if _, exists := matches[key]; !exists {
|
||||
matches[key] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.allIDs = slices.Collect(maps.Keys(allIDs))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func addToSet(m map[int]map[string]struct{}, k int, v string) {
|
||||
if m[k] == nil {
|
||||
m[k] = map[string]struct{}{}
|
||||
}
|
||||
m[k][v] = struct{}{}
|
||||
}
|
||||
|
||||
// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct
|
||||
// artist IDs the track credits.
|
||||
type scoredTrack struct {
|
||||
sanitizedTrack
|
||||
overlap int
|
||||
}
|
||||
|
||||
// queryAccum tallies, for one track against one query, the overlap count, the credited artists'
|
||||
// owned IDs, and their MBIDs.
|
||||
type queryAccum struct {
|
||||
overlap int
|
||||
ids map[string]struct{}
|
||||
mbids map[string]struct{}
|
||||
}
|
||||
|
||||
func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack {
|
||||
queriesByArtist := make(map[string][]int)
|
||||
for idx, ids := range r.byQuery {
|
||||
for id := range ids {
|
||||
queriesByArtist[id] = append(queriesByArtist[id], idx)
|
||||
}
|
||||
}
|
||||
|
||||
byQuery := make(map[int][]scoredTrack, len(r.byQuery))
|
||||
for i := range tracks {
|
||||
acc := map[int]*queryAccum{}
|
||||
credited := map[string]struct{}{}
|
||||
for _, p := range tracks[i].Participants[model.RoleArtist] {
|
||||
if _, dup := credited[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
owners, owned := queriesByArtist[p.ID]
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
credited[p.ID] = struct{}{}
|
||||
mbid := r.mbid[p.ID] // "" if not in the artist-table result
|
||||
for _, idx := range owners {
|
||||
a := acc[idx]
|
||||
if a == nil {
|
||||
a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}}
|
||||
acc[idx] = a
|
||||
}
|
||||
a.overlap++
|
||||
a.ids[p.ID] = struct{}{}
|
||||
if mbid != "" {
|
||||
a.mbids[mbid] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx, a := range acc {
|
||||
byQuery[idx] = append(byQuery[idx], scoredTrack{
|
||||
sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids),
|
||||
overlap: a.overlap,
|
||||
})
|
||||
}
|
||||
}
|
||||
return byQuery
|
||||
}
|
||||
|
||||
// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as
|
||||
// the main artist (role='artist', not albumartist — that avoids tribute/compilation false
|
||||
// positives). The non-correlated id IN (subquery) materializes the matching ids once from the
|
||||
// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs
|
||||
// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr
|
||||
// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the
|
||||
// cleaner home if this is reused.
|
||||
func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) {
|
||||
if len(artistIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
args := slice.Map(artistIDs, func(id string) any { return id })
|
||||
return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Expr(
|
||||
"media_file.id IN (SELECT media_file_id FROM media_file_artists "+
|
||||
"WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
|
||||
@@ -566,32 +412,34 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) {
|
||||
func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
||||
preferStarred := conf.Server.Matcher.PreferStarred
|
||||
for _, c := range candidates {
|
||||
titleSim := similarityRatio(q.title, c.title)
|
||||
for _, t := range sanitizedTracks {
|
||||
titleSim := similarityRatio(q.title, t.title)
|
||||
|
||||
if titleSim < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
var albumSim float64
|
||||
if q.album != "" {
|
||||
albumSim = similarityRatio(q.album, c.album)
|
||||
albumSim = similarityRatio(q.album, t.album)
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, c.mf.Duration),
|
||||
preferredMatch: preferStarred && isPreferredTrack(c.mf),
|
||||
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
|
||||
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold),
|
||||
artistOverlap: c.overlap,
|
||||
specificityLevel: computeSpecificityLevel(q, t, threshold),
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = *c.mf
|
||||
bestMatch = *t.mf
|
||||
found = true
|
||||
}
|
||||
}
|
||||
@@ -602,34 +450,66 @@ func isPreferredTrack(mf *model.MediaFile) bool {
|
||||
return mf.Starred || mf.Rating >= 4
|
||||
}
|
||||
|
||||
// orderAndDedup builds the final ordered result from the per-index matches,
|
||||
// applying the count limit and deduplication. A library track is added at most
|
||||
// once unless the same input song appears more than once (callers rely on that
|
||||
// 1:1 positional behavior for identical duplicate inputs).
|
||||
func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles {
|
||||
mfs := make(model.MediaFiles, 0, len(songs))
|
||||
addedBy := make(map[string]int, len(songs))
|
||||
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
||||
func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
for _, s := range songs {
|
||||
if songMatchedIn(s, priorMatches...) {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, songQuery{
|
||||
title: str.SanitizeFieldForSorting(s.Name),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
|
||||
artistMBID: s.ArtistMBID,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
durationMs: s.Duration,
|
||||
})
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
for i, s := range songs {
|
||||
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
|
||||
// library tracks using priority order: ID > MBID > ISRC > title+artist.
|
||||
func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
mfs := make(model.MediaFiles, 0, len(songs))
|
||||
addedBy := make(map[string]agents.Song, len(songs))
|
||||
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
mf, found := matches[i]
|
||||
|
||||
mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
if !s.Equals(songs[prevIdx]) {
|
||||
|
||||
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
if t != prevSong {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
addedBy[mf.ID] = i
|
||||
addedBy[mf.ID] = t
|
||||
}
|
||||
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
// findMatchingTrack looks up a song in the match maps using priority order.
|
||||
func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
|
||||
return mf, true
|
||||
}
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
return mf, true
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
||||
func similarityRatio(a, b string) float64 {
|
||||
if a == b {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package matcher
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -54,205 +51,3 @@ var _ = Describe("similarityRatio", func() {
|
||||
Expect(ratio1).To(Equal(ratio2))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("matcher internals", func() {
|
||||
It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() {
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
artists: []queryArtist{{mbid: "artist-mbid-1"}},
|
||||
albumMBID: "album-mbid-1",
|
||||
}
|
||||
mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty
|
||||
t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}})
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5))
|
||||
})
|
||||
|
||||
It("computeSpecificityLevel maximizes the level over all query artists", func() {
|
||||
// First artist does not match; second matches by name with a matching album → level 3.
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
album: "violator",
|
||||
artists: []queryArtist{
|
||||
{name: "no match"},
|
||||
{name: "depeche mode"},
|
||||
},
|
||||
}
|
||||
mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"}
|
||||
t := newSanitizedTrack(&mf, nil, nil)
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3))
|
||||
})
|
||||
|
||||
It("scores MBID specificity for any credited artist, not just the last", func() {
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
artists: []queryArtist{
|
||||
{name: "drake", mbid: "mbz-drake"},
|
||||
{name: "future", mbid: "mbz-future"},
|
||||
},
|
||||
album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+
|
||||
}
|
||||
// Track credits BOTH MBIDs; with the old last-wins string this would only match one.
|
||||
t := sanitizedTrack{
|
||||
mf: &model.MediaFile{},
|
||||
title: "song",
|
||||
artist: "drake",
|
||||
album: "some other album",
|
||||
artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}},
|
||||
}
|
||||
// Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is
|
||||
// reached via mbz-future too, which the old code would have dropped.
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() {
|
||||
// A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the
|
||||
// strongest identity signal, so the track's album still elevates specificity above 0.
|
||||
q := songQuery{
|
||||
title: "song",
|
||||
artists: []queryArtist{{id: "artist-1"}},
|
||||
album: "violator",
|
||||
albumMBID: "album-mbid-1",
|
||||
}
|
||||
// Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin).
|
||||
mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"}
|
||||
t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil)
|
||||
// Album MBID matches → level 5 via the ID identity, where the old code scored 0.
|
||||
Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5))
|
||||
|
||||
// Same artist, album name matches but no album MBID → level 4 via the ID identity.
|
||||
q.albumMBID = ""
|
||||
mf2 := model.MediaFile{Title: "Song", Album: "Violator"}
|
||||
t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil)
|
||||
Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("groupQueries", func() {
|
||||
It("builds one query per unmatched song carrying all artists", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].index).To(Equal(0))
|
||||
Expect(queries[0].query.title).To(Equal("song a"))
|
||||
Expect(queries[0].query.artists).To(HaveLen(2))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("drake"))
|
||||
Expect(queries[0].query.artists[1].name).To(Equal("future"))
|
||||
})
|
||||
|
||||
It("strips leading articles from artist names", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("drake"))
|
||||
})
|
||||
|
||||
It("keeps an artist that carries only an ID (empty name)", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].id).To(Equal("ar-x"))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal(""))
|
||||
})
|
||||
|
||||
It("keeps an artist that carries only an MBID (empty id and name)", func() {
|
||||
// ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display
|
||||
// name; the MBID is a usable identity signal and must not be dropped.
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].id).To(Equal(""))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal(""))
|
||||
Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future"))
|
||||
})
|
||||
|
||||
It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists).To(HaveLen(1))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("future"))
|
||||
})
|
||||
|
||||
It("skips already-matched songs and songs with no usable artist", func() {
|
||||
songs := []agents.Song{
|
||||
{Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}},
|
||||
{Name: "No Artist"},
|
||||
{Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}},
|
||||
}
|
||||
queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}})
|
||||
Expect(queries).To(HaveLen(1))
|
||||
Expect(queries[0].index).To(Equal(2))
|
||||
Expect(queries[0].query.artists[0].name).To(Equal("future"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("bucketTracks", func() {
|
||||
It("scores tracks by how many of the query's artists they credit (overlap)", func() {
|
||||
r := resolvedArtists{
|
||||
byQuery: map[int]map[string]struct{}{
|
||||
0: {"ar-1": {}, "ar-2": {}},
|
||||
},
|
||||
mbid: map[string]string{},
|
||||
}
|
||||
trackA := model.MediaFile{ID: "a", Title: "A",
|
||||
Participants: artistParticipants(
|
||||
model.Artist{ID: "ar-1", OrderArtistName: "one"},
|
||||
model.Artist{ID: "ar-2", OrderArtistName: "two"},
|
||||
),
|
||||
}
|
||||
trackB := model.MediaFile{ID: "b", Title: "B",
|
||||
Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}),
|
||||
}
|
||||
byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB})
|
||||
Expect(byQuery[0]).To(HaveLen(2))
|
||||
overlaps := map[string]int{}
|
||||
for _, st := range byQuery[0] {
|
||||
overlaps[st.mf.ID] = st.overlap
|
||||
}
|
||||
Expect(overlaps["a"]).To(Equal(2))
|
||||
Expect(overlaps["b"]).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveArtists ID fast-path", func() {
|
||||
It("owns an artist supplied by ID without a name match", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
artistRepo := tests.CreateMockArtistRepo()
|
||||
artistRepo.SetData(model.Artists{
|
||||
{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"},
|
||||
})
|
||||
ds := &tests.MockDataStore{MockedArtist: artistRepo}
|
||||
m := New(ds)
|
||||
|
||||
queries := []indexedQuery{
|
||||
{index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}},
|
||||
}
|
||||
res, err := m.resolveArtists(ctx, queries)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.byQuery[0]).To(HaveKey("ar-x"))
|
||||
Expect(res.allIDs).To(ContainElement("ar-x"))
|
||||
Expect(res.mbid["ar-x"]).To(Equal("mbz-x"))
|
||||
})
|
||||
})
|
||||
|
||||
// artistParticipants builds a Participants map crediting the given artists under RoleArtist.
|
||||
func artistParticipants(artists ...model.Artist) model.Participants {
|
||||
list := make(model.ParticipantList, len(artists))
|
||||
for i, a := range artists {
|
||||
list[i] = model.Participant{Artist: a}
|
||||
}
|
||||
return model.Participants{model.RoleArtist: list}
|
||||
}
|
||||
+124
-595
File diff suppressed because it is too large.
Load diff
@@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
@@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
|
||||
// FS info
|
||||
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
|
||||
data.FS.Data = getFSInfo(conf.Server.DataFolder.String())
|
||||
if conf.Server.CacheFolder.String() != "" {
|
||||
data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
|
||||
data.FS.Data = getFSInfo(conf.Server.DataFolder)
|
||||
if conf.Server.CacheFolder != "" {
|
||||
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
|
||||
}
|
||||
if conf.Server.Backup.Path.String() != "" {
|
||||
data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
|
||||
if conf.Server.Backup.Path != "" {
|
||||
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
|
||||
}
|
||||
|
||||
// Config info
|
||||
@@ -223,7 +223,6 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
@@ -43,46 +43,45 @@ type Data struct {
|
||||
FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
|
||||
} `json:"library"`
|
||||
Config struct {
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScannerExtractor string `json:"scannerExtractor,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"`
|
||||
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
BackupCount int `json:"backupCount,omitempty"`
|
||||
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
|
||||
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
|
||||
HasCustomPID bool `json:"hasCustomPID,omitempty"`
|
||||
HasCustomTags bool `json:"hasCustomTags,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScannerExtractor string `json:"scannerExtractor,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"`
|
||||
CoverArtQuality int `json:"coverArtQuality,omitempty"`
|
||||
EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"`
|
||||
UICoverArtSize int `json:"uiCoverArtSize,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
BackupCount int `json:"backupCount,omitempty"`
|
||||
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
|
||||
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
|
||||
HasCustomPID bool `json:"hasCustomPID,omitempty"`
|
||||
HasCustomTags bool `json:"hasCustomTags,omitempty"`
|
||||
} `json:"config"`
|
||||
Plugins map[string]PluginInfo `json:"plugins,omitempty"`
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ func (j *Executor) start(ctx context.Context) error {
|
||||
|
||||
func (j *Executor) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
|
||||
@@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool {
|
||||
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
|
||||
start := time.Now()
|
||||
end := start.Add(timeout)
|
||||
var retries = 0
|
||||
var retries int = 0
|
||||
|
||||
for {
|
||||
fileInfo, err := os.Stat(path)
|
||||
|
||||
Loaded 100 of 799 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user