Merge branch 'master' into radio-browser-search/5239

This commit is contained in:
Markus Busche 2026-07-14 15:54:48 +02:00 committed by GitHub
commit 552b1a7452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
623 changed files with 29389 additions and 8371 deletions

View File

@ -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@v7
- uses: actions/github-script@v9
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml

View File

@ -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@v6
- uses: actions/checkout@v7
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: $(git describe --tags `git rev-list --tags --max-count=1`)"
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
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 tag --sort=-committerdate | head -n 1)
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
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@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@ -96,12 +96,33 @@ 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@v6
uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@ -127,7 +148,7 @@ jobs:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
@ -145,7 +166,7 @@ jobs:
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
@ -199,7 +220,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
@ -230,7 +251,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: |
set -e
for file in resources/i18n/*.json; do
@ -257,7 +278,7 @@ jobs:
build:
name: Build
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled]
needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
@ -276,7 +297,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@ -300,6 +321,21 @@ 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:
@ -350,7 +386,7 @@ jobs:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Download digests
uses: actions/download-artifact@v8
@ -384,7 +420,7 @@ jobs:
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Download digests
uses: actions/download-artifact@v8
@ -437,7 +473,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
@ -471,7 +507,7 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
@ -491,7 +527,7 @@ jobs:
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
version: '2.16.0'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 2

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Get updated translations
id: poeditor
env:

150
.github/workflows/validate-migrations.sh vendored Executable file
View File

@ -0,0 +1,150 @@
#!/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"

3
.gitignore vendored
View File

@ -37,5 +37,6 @@ AGENTS.md
*.wasm
*.ndp
openspec/
.agents
go.work*
.worktrees/
.worktrees/

View File

@ -13,6 +13,7 @@ linters:
- dogsled
- durationcheck
- errorlint
- forbidigo
- gocritic
- gocyclo
- goprintffuncname
@ -36,6 +37,14 @@ 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
@ -45,6 +54,9 @@ linters:
- gosec
path: _test\.go
text: "G703"
- path-except: 'db/migrations/'
linters:
- forbidigo
generated: lax
presets:
- comments
@ -56,6 +68,7 @@ linters:
- builtin$
- examples$
- node_modules
- _gen\.go$
formatters:
exclusions:
generated: lax

View File

@ -69,12 +69,15 @@ 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=netgo,sqlite_fts5 -ldflags="-w -s \
go build -tags="${BUILD_TAGS}" -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; }
@ -108,11 +111,12 @@ 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)
cat "$(go env GOENV)" 2>/dev/null || true
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
# So let's use gcc for everything except Darwin.
@ -121,14 +125,25 @@ 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
go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \
BUILD_TAGS=$(./release/build-tags.sh)
go build -tags="${BUILD_TAGS}" -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
@ -159,7 +174,6 @@ 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}

View File

@ -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 `git rev-list --tags --max-count=1`)-SNAPSHOT
GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT
else
GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
@ -113,10 +113,11 @@ 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 . -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
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
go mod tidy -C plugins/pdk/go
.PHONY: gen

View File

@ -52,6 +52,7 @@ 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**

View File

@ -1,7 +1,7 @@
package deezer
import (
bytes "bytes"
"bytes"
"context"
"encoding/json"
"errors"

View File

@ -8,7 +8,6 @@ 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"
)
@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
return &mf
return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {
@ -109,7 +107,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", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
)
})
@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() {
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
{Start: new(int64(0)), Value: "This is"},
{Start: new(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,

View File

@ -231,10 +231,9 @@ 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,
Artist: t.Artist.Name,
ArtistMBID: t.Artist.MBID,
Name: t.Name,
MBID: t.MBID,
Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
})
}
return res, nil

View File

@ -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", 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"},
{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"}}},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))

View File

@ -77,6 +77,13 @@ 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)
}
@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
uid, err := p.String("uid")
linkToken, 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

View File

@ -0,0 +1,218 @@
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"))
})
})
})

View File

@ -0,0 +1,50 @@
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
}

View File

@ -141,24 +141,37 @@ 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,
Artist: t.ArtistName,
ArtistMBID: mbid,
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
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
@ -203,7 +216,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artist: song.Artist,
Artists: []agents.Artist{{Name: song.Artist}},
MBID: song.MBID,
Name: song.Name,
}

View File

@ -249,24 +249,22 @@ 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",
Artist: "Mili",
ArtistMBID: "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",
Artists: []agents.Artist{{Name: "Mili", MBID: "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",
Artist: "Mili",
ArtistMBID: "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",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
@ -278,17 +276,45 @@ 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",
Artist: "Mili",
ArtistMBID: "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",
Artists: []agents.Artist{{Name: "Mili", MBID: "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() {
@ -393,26 +419,24 @@ 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: "",
Artist: "aha",
ArtistMBID: "",
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: "",
Artists: []agents.Artist{{Name: "aha"}},
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artist: "Wham!",
ArtistMBID: "",
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artists: []agents.Artist{{Name: "Wham!"}},
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
@ -427,15 +451,14 @@ 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: "",
Artist: "aha",
ArtistMBID: "",
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: "",
Artists: []agents.Artist{{Name: "aha"}},
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})

View File

@ -75,7 +75,7 @@ var (
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
conf.Server.Backup.Path = conf.NewDir(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 = backupDir
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
if backupCount != -1 {

View File

@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{
},
}
var marshalers = map[string]func(interface{}) ([]byte, error){
var marshalers = map[string]func(any) ([]byte, error){
"pretty": prettyMarshal,
"toml": toml.Marshal,
"yaml": yaml.Marshal,
"json": json.Marshal,
"jsonindent": func(v interface{}) ([]byte, error) {
"jsonindent": func(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
},
}
func prettyMarshal(v interface{}) ([]byte, error) {
func prettyMarshal(v any) ([]byte, error) {
out := v.([]core.InspectOutput)
var res strings.Builder
for i := range out {

558
cmd/plugin.go Normal file
View File

@ -0,0 +1,558 @@
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)
}

360
cmd/plugin_test.go Normal file
View File

@ -0,0 +1,360 @@
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))
})
})

View File

@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) {
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBOptimizer(ctx))
g.Go(scheduleDBAnalyzer(ctx))
g.Go(startPluginManager(ctx))
g.Go(runInitialScan(ctx))
if conf.Server.Scanner.Enabled {
@ -275,16 +275,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
}
}
func scheduleDBOptimizer(ctx context.Context) func() error {
func scheduleDBAnalyzer(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
if !conf.Server.EnableScheduledDBAnalyze {
log.Info(ctx, "Scheduled DB analysis is DISABLED")
return nil
}
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
schedulerInstance := scheduler.GetInstance()
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
_, 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")
return
}
db.Optimize(ctx)
defer release()
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
log.Error(ctx, "Error analyzing DB", err)
}
})
return err
}

View File

@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/gob"
"errors"
"fmt"
"os"
"strings"
@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{
},
}
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
var changesDetected bool
var scanErrors []error
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 {
@ -59,6 +65,7 @@ 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) {
@ -95,6 +102,16 @@ 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)
@ -104,7 +121,21 @@ func runScanner(ctx context.Context) {
if subprocess {
trackScanAsSubprocess(ctx, progress)
} else {
trackScanInteractively(ctx, progress)
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)
}
}
}

View File

@ -1,14 +1,29 @@
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

View File

@ -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
options["LogDirectory"] = conf.Server.DataFolder.String()
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
options["LogDirectory"] = conf.Server.DataFolder
options["LogDirectory"] = conf.Server.DataFolder.String()
}
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)
println(" data folder: " + conf.Server.DataFolder.String())
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
println(" logs folder: " + conf.Server.DataFolder)
println(" logs folder: " + conf.Server.DataFolder.String())
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
@ -232,22 +232,21 @@ func buildExecuteCmd() *cobra.Command {
}
const systemdScript = `[Unit]
Description={{.Description}}
ConditionFileIsExecutable={{.Path|cmdEscape}}
{{range $i, $dep := .Dependencies}}
{{$dep}} {{end}}
Description={{Description}}
ConditionFileIsExecutable={{Path | cmdEscape}}
{{range Dependencies}}{{.}}
{{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
@ -260,7 +259,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]

55
cmd/svc_test.go Normal file
View File

@ -0,0 +1,55 @@
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])
}
}
})
})

View File

@ -109,7 +109,7 @@ 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(manager)
lyricsLyrics := lyrics.NewLyrics(dataStore, 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)

View File

@ -2,9 +2,7 @@ package configtest
import "github.com/navidrome/navidrome/conf"
// TODO Remove this redirection and call SnapshotConfig directly from tests
func SetupConfig() func() {
oldValues := *conf.Server
return func() {
conf.Server = &oldValues
}
return conf.SnapshotConfig()
}

View File

@ -2,6 +2,7 @@ package conf
import (
"cmp"
"encoding/json"
"fmt"
"net/url"
"os"
@ -14,6 +15,7 @@ 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"
@ -29,8 +31,8 @@ type configOptions struct {
UnixSocketPerm string
EnforceNonRootUser bool
MusicFolder string
DataFolder string
CacheFolder string
DataFolder Dir
CacheFolder Dir
DbPath string
LogLevel string
LogFile string
@ -45,11 +47,11 @@ 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
@ -111,6 +113,7 @@ 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"`
@ -145,22 +148,29 @@ 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
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"
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
}
type subsonicOptions struct {
@ -229,7 +239,7 @@ type jukeboxOptions struct {
type backupOptions struct {
Count int
Path string
Path Dir
Schedule string
}
@ -247,7 +257,7 @@ type inspectOptions struct {
type pluginsOptions struct {
Enabled bool
Folder string
Folder Dir
CacheSize string
AutoReload bool
LogLevel string
@ -287,6 +297,22 @@ 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()
@ -306,8 +332,15 @@ func Load(noConfigDump bool) {
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
err := viper.Unmarshal(&Server)
err := viper.Unmarshal(&Server, viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
mapstructure.TextUnmarshallerHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
))
if err != nil {
logFatal("Error parsing config:", err)
}
@ -317,48 +350,28 @@ func Load(noConfigDump bool) {
logFatal(err)
}
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.CacheFolder.String() == "" {
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
}
if Server.Plugins.Enabled {
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)
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)
}
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
logFatal("Error creating backup path:", err)
}
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
}
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()))
@ -445,6 +458,7 @@ 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")
@ -636,7 +650,7 @@ func validateScanSchedule() error {
}
func validateBackupSchedule() error {
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
@ -733,7 +747,6 @@ 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)
@ -765,7 +778,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", ".lrc,.txt,embedded")
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
@ -781,12 +794,13 @@ func setViperDefaults() {
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", false)
viper.SetDefault("enablesharing", true)
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)
@ -807,9 +821,11 @@ 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)
@ -818,6 +834,9 @@ 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)
@ -873,7 +892,6 @@ 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)
}

View File

@ -58,6 +58,19 @@ 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")
@ -186,27 +199,12 @@ 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 opening log file")))
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("is called when BaseURL is invalid", func() {

77
conf/dir.go Normal file
View File

@ -0,0 +1,77 @@
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
}

164
conf/dir_test.go Normal file
View File

@ -0,0 +1,164 @@
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()
})
})
})

View File

@ -14,9 +14,16 @@ 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"
@ -25,7 +32,8 @@ const (
DefaultSessionTimeout = 48 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year
OptimizeDBSchedule = "@every 24h"
DBAnalyzeCheckSchedule = "@every 30m"
DBAnalyzeMaxAge = 24 * time.Hour
// 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
@ -153,25 +161,25 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
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 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
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 -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
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 -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
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 -",
},
}
)

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/gohugoio/hashstructure"
"github.com/navidrome/navidrome/model"
)
@ -33,15 +34,22 @@ type ExternalImage struct {
}
type Song struct {
ID string
Name string
MBID string
ISRC string
Artist string
ArtistMBID string
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
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
}
var (

View File

@ -0,0 +1,27 @@
package agents
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Song.Equals", func() {
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
It("true for identical songs incl Artists", func() {
Expect(base.Equals(base)).To(BeTrue())
})
It("false when Artists differ", func() {
other := base
other.Artists = []Artist{{ID: "y", Name: "B"}}
Expect(base.Equals(other)).To(BeFalse())
})
It("false when a scalar differs", func() {
other := base
other.Name = "T"
Expect(base.Equals(other)).To(BeFalse())
})
It("true when both have empty Artists and equal scalars", func() {
a := Song{ID: "1", Name: "S"}
Expect(a.Equals(a)).To(BeTrue())
})
})

View File

@ -3,6 +3,7 @@ package core
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
@ -60,7 +61,15 @@ 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)
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
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
}
}
}
err = z.Close()
@ -120,7 +129,12 @@ 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)
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
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
}
mf.Path = file
zippedMfs[idx] = mf
}
@ -162,6 +176,27 @@ 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,
@ -172,23 +207,6 @@ 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)

View File

@ -89,6 +89,32 @@ 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{

View File

@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID
// Configure cache
conf.Server.ImageCacheSize = cacheSize
conf.Server.CacheFolder = tmpDir
conf.Server.CacheFolder = conf.NewDir(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 g := 0; g < n; g++ {
for range n {
go func() {
defer wg.Done()
r, _, err := aw.Get(context.Background(), artID, 300, true)

View File

@ -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 := 0; y < height; y++ {
for x := 0; x < width; x++ {
for y := range height {
for x := range width {
r := uint8((x * 255) / width)
g := uint8((y * 255) / height)
b := uint8(((x + y) * 255) / (width + height))

View File

@ -357,6 +357,98 @@ 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/

View File

@ -2,6 +2,7 @@ package artworke2e_test
import (
"context"
"fmt"
"path/filepath"
"testing"
@ -63,7 +64,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 = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
conf.Server.MusicFolder = fakeLibPath
conf.Server.DevExternalScanner = false
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
@ -104,3 +105,16 @@ 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{}
}

View File

@ -113,28 +113,12 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
return nil, nil, nil, err
}
folderIDSet := make(map[string]bool, len(folderIDs))
for _, id := range folderIDs {
folderIDSet[id] = true
parent, err := albumRootParent(ctx, ds, folders, folderIDs)
if err != nil {
return nil, nil, nil, err
}
// 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)
}
}
if parent != nil {
folders = append(folders, *parent)
}
var paths []string
@ -159,6 +143,50 @@ 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 {

View File

@ -339,6 +339,61 @@ 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{
{

View File

@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
conf.Server.DataFolder = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
// Create the artwork/artist directory
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
@ -702,12 +702,20 @@ 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 {

View File

@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
conf.Server.DataFolder = tempDir
conf.Server.DataFolder = conf.NewDir(tempDir)
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed())

View File

@ -21,6 +21,12 @@ 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 {
@ -126,6 +132,22 @@ 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 {
@ -163,6 +185,7 @@ 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)

View File

@ -21,8 +21,7 @@ func TestAuth(t *testing.T) {
}
const (
testJWTSecret = "not so secret"
oneDay = 24 * time.Hour
oneDay = 24 * time.Hour
)
var _ = BeforeSuite(func() {

View File

@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
return album, err
}
album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalInfoUpdatedAt = new(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(time.Now())
artist.ExternalInfoUpdatedAt = new(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,
@ -471,13 +471,19 @@ 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 songs with artist info if not already present (for top songs, we know the artist)
// 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.
for i := range songs {
if songs[i].Artist == "" {
songs[i].Artist = artistName
}
if songs[i].ArtistMBID == "" {
songs[i].ArtistMBID = artist.MbzArtistID
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
}
}
}

View File

@ -272,12 +272,11 @@ 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: &recentTime,
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)),
}
mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/cached-large.jpg")
@ -304,12 +303,11 @@ 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: &expiredTime,
ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)),
}
mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe()
expectedURL, _ := url.Parse("http://example.com/expired-large.jpg")

View File

@ -3,6 +3,7 @@ package external_test
import (
"context"
"errors"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
@ -56,7 +57,14 @@ 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"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
// 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}},
}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
@ -65,16 +73,19 @@ 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: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
}, nil).Once()
// Mock loadTracksByID - no ID matches
// 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.
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
})).Return(model.MediaFiles{}, nil).Maybe()
// Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
// MBID phase: won't fire (empty MBID).
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
@ -88,18 +99,19 @@ var _ = Describe("Provider - SimilarSongs", func() {
return hasMBID
})).Return(model.MediaFiles{}, nil).Maybe()
// Mock loadTracksByTitleAndArtist - queries by artist name
// Matcher track-fetch: subquery returns the matched song with participants.
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
if !ok {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
for _, f := range and {
sql, _, err := f.ToSql()
if err == nil && strings.Contains(sql, "media_file_artists") {
return true
}
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
return false
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
@ -165,7 +177,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", Artist: "Depeche Mode"},
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
}, nil).Once()
// Mock loadTracksByID - no ID matches
@ -242,7 +254,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", Artist: "Depeche Mode"},
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
}, nil).Once()
// Mock loadTracksByID - no ID matches

View File

@ -76,6 +76,63 @@ 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()
@ -148,6 +205,8 @@ 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{
@ -159,7 +218,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 fallback for song2
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match
songs, err := p.TopSongs(ctx, "Artist One", 2)
@ -195,6 +254,8 @@ 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{
@ -203,10 +264,16 @@ var _ = Describe("Provider - TopSongs", func() {
}
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
// 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"}
// 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}},
}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)
@ -224,6 +291,8 @@ 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{
@ -236,8 +305,12 @@ 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()
// 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"}
// 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}},
}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 2)

View File

@ -12,7 +12,6 @@ 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"
@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://cached.com/album",
Description: "Cached Desc",
LargeImageUrl: "http://cached.com/large.jpg",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://expired.com/album",
Description: "Expired Desc",
LargeImageUrl: "http://expired.com/large.jpg",
ExternalInfoUpdatedAt: gg.P(expiredTime),
ExternalInfoUpdatedAt: new(expiredTime),
}
mockAlbumRepo.SetData(model.Albums{*originalAlbum})

View File

@ -13,7 +13,6 @@ 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"
@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ExternalUrl: "http://cached.url",
Biography: "Cached Bio",
LargeImageUrl: "http://cached_large.jpg",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-similar-present", Name: "Similar Present"},
{ID: "ar-similar-absent", Name: "Similar Absent"},
@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-expired",
Name: "Expired Artist",
ExternalInfoUpdatedAt: gg.P(expiredTime),
ExternalInfoUpdatedAt: new(expiredTime),
SimilarArtists: model.Artists{
{ID: "ar-exp-similar", Name: "Expired Similar"},
},
@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{
ID: "ar-similar-test",
Name: "Similar Test Artist",
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{
{ID: "ar-sim-present", Name: "Similar Present"},
{ID: "", Name: "Similar Absent Raw"},

View File

@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
@ -325,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error {
func (j *ffCmd) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
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
@ -394,14 +394,23 @@ func isDefaultCommand(format, command string) bool {
// including all transcoding parameters (bitrate, sample rate, channels).
func buildDynamicArgs(opts TranscodeOptions) []string {
cmdPath, _ := ffmpegCmd()
args := []string{cmdPath, "-i", opts.FilePath}
args := []string{cmdPath}
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)
}
@ -491,11 +500,20 @@ 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))

View File

@ -7,7 +7,7 @@ import (
"path/filepath"
"runtime"
"strings"
sync "sync"
"sync"
"testing"
"time"
@ -47,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 -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", "-"}))
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", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
It("adds time offset before the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
})
})
@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
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())
})
It("returns true for known default opus command", func() {
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
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())
})
It("returns true for known default aac command", func() {
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
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())
})
It("returns true for known default flac command", func() {
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
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())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
@ -113,6 +113,7 @@ 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",
@ -132,6 +133,7 @@ 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",
@ -149,6 +151,7 @@ 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",
@ -165,9 +168,11 @@ var _ = Describe("ffmpeg", func() {
Offset: 30,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.mp3",
"ffmpeg",
"-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",
@ -185,6 +190,7 @@ 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",
@ -202,6 +208,7 @@ 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",

View File

@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
svc = core.NewImageUploadService()
})

View File

@ -7,7 +7,6 @@ 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 {
@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
MappedTags: P(md.ToMediaFile(libraryId, folderId)),
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
}
return result, nil

View File

@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
return r.mapError(err)
}
err = r.LibraryRepository.Delete(libID)
// 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")
if err != nil {
return r.mapError(err)
}

View File

@ -4,56 +4,122 @@ 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"
)
// Lyrics can fetch lyrics for a media file.
type Lyrics interface {
// 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.
type Lyrics interface {
Provider
GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
}
// PluginLoader discovers and loads lyrics provider plugins.
type PluginLoader interface {
LoadLyricsProvider(name string) (Lyrics, bool)
LoadLyricsProvider(name string) (Provider, 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(pluginLoader PluginLoader) Lyrics {
return &lyricsService{pluginLoader: pluginLoader}
func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
return &lyricsService{ds: ds, 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) {
var lyricsList model.LyricList
var err error
return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
}
// 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)
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)
if pattern == "" {
continue
}
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
for _, mf := range mediaFiles {
if mf == nil {
continue
}
if len(lyricsList) > 0 {
return lyricsList, nil
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
}
}
}
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)
}
}

View File

@ -1,9 +1,13 @@
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"
@ -15,3 +19,17 @@ 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" }

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -12,18 +13,23 @@ 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("sources", func() {
var _ = Describe("Lyrics", func() {
var mf model.MediaFile
var ctx context.Context
const badLyrics = "This is a set of lyrics\nThat is not good"
unsynced, _ := model.ToLyrics("xxx", badLyrics)
embeddedLyrics := model.LyricList{*unsynced}
embeddedLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{Value: "This is a set of lyrics"},
{Value: "That is not good"},
},
},
}
syncedLyrics := model.LyricList{
model.Lyrics{
@ -32,15 +38,80 @@ var _ = Describe("sources", func() {
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
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",
},
},
Synced: true,
},
}
@ -60,6 +131,25 @@ var _ = Describe("sources", 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())
@ -69,19 +159,104 @@ var _ = Describe("sources", func() {
Lyrics: string(lyricsJson),
Path: "tests/fixtures/test.mp3",
}
ctx = context.Background()
ctx = GinkgoT().Context()
})
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, 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("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
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"},
}))
})
Context("Errors", func() {
var RegularUserContext = XContext
@ -111,7 +286,7 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
@ -120,7 +295,7 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
@ -138,7 +313,7 @@ var _ = Describe("sources", func() {
It("should return lyrics from a plugin", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -148,7 +323,7 @@ var _ = Describe("sources", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mf.Lyrics = "" // No embedded lyrics
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -157,7 +332,7 @@ var _ = Describe("sources", func() {
It("should skip plugin if embedded has lyrics", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
@ -166,7 +341,7 @@ var _ = Describe("sources", func() {
It("should skip unknown plugin names gracefully", func() {
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
mockLoader.notFound = true
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
@ -176,7 +351,7 @@ var _ = Describe("sources", func() {
conf.Server.LyricsPriority = "MyLyricsPlugin"
mockLoader.pluginName = "MyLyricsPlugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -185,12 +360,56 @@ var _ = Describe("sources", func() {
It("should handle plugin error gracefully", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
mockLoader.err = fmt.Errorf("plugin error")
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, 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 {
@ -207,7 +426,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string {
return []string{"test-lyrics-plugin"}
}
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
if m.notFound {
return nil, false
}

View File

@ -3,9 +3,12 @@ package lyrics
import (
"context"
"errors"
"os"
"fmt"
"io"
"io/fs"
"path"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/ioutils"
@ -23,31 +26,46 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er
}
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
basePath := mf.AbsolutePath()
ext := path.Ext(basePath)
ext := path.Ext(mf.Path)
sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix
ctx = log.NewContext(ctx, "file", sidecarRelPath)
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
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)
}
contents, err := ioutils.UTF8ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
f, err := fsys.Open(sidecarRelPath)
if errors.Is(err, fs.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path")
return nil, nil
} else if err != nil {
return nil, err
}
defer f.Close()
lyrics, err := model.ToLyrics("xxx", string(contents))
contents, err := io.ReadAll(ioutils.UTF8Reader(f))
if err != nil {
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
return nil, err
} else if lyrics == nil {
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
}
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")
return nil, nil
}
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
return model.LyricList{*lyrics}, nil
log.Trace(ctx, "retrieved lyrics from external file")
return list, nil
}
// fromPlugin attempts to load lyrics from a plugin with the given name.

View File

@ -3,15 +3,19 @@ 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() {
ctx := context.Background()
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
})
Describe("fromEmbedded", func() {
It("should return nothing for a media file with no lyrics", func() {
@ -26,10 +30,12 @@ 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"
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics))
unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics))
synced, _ := syncedList.Main()
unsynced, _ := unsyncedList.Main()
expectedList := model.LyricList{*synced, *unsynced}
expectedList := model.LyricList{synced, unsynced}
lyricsJson, err := json.Marshal(expectedList)
Expect(err).ToNot(HaveOccurred())
@ -54,93 +60,94 @@ 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() {
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(0))
})
It("should return synchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
// 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).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,
},
}))
})
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() {
// 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")
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".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(gg.P(int64(0))))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
})
It("should handle UTF-16 LE encoded LRC files", func() {
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".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(gg.P(int64(18800))))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
Expect(lyrics[0].Line[1].Start).To(Equal(new(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"))
})
})
})

124
core/matcher/doc.go Normal file
View File

@ -0,0 +1,124 @@
// 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.01.0)
// 2. Duration proximity (closer duration scores higher; 1.0 when the agent
// reports no duration)
// 3. Specificity level (05, 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

View File

@ -3,12 +3,16 @@ 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"
)
@ -23,176 +27,73 @@ func New(ds model.DataStore) *Matcher {
return &Matcher{ds: ds}
}
// MatchSongs matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
// 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.
//
// # 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.
// 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.
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
if len(songs) == 0 {
return nil, nil
}
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
matches, err := m.resolveMatches(ctx, songs)
if err != nil {
return nil, err
}
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
return orderAndDedup(songs, matches, count), nil
}
// 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.
// 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.
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)
}
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
// 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) {
result := make(map[int]model.MediaFile, len(songs))
for i, t := range songs {
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
result[i] = mf
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)
}
log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err)
}
return result, nil
}
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) {
// matchByID fills result with direct ID matches.
func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]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 matches, nil
return nil
}
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
@ -201,27 +102,37 @@ func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[
},
})
if err != nil {
return matches, err
return err
}
byID := make(map[string]model.MediaFile, len(res))
for _, mf := range res {
if _, ok := matches[mf.ID]; !ok {
matches[mf.ID] = mf
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
}
}
return matches, nil
return nil
}
// 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) {
// 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 {
var mbids []string
for _, s := range songs {
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
for i, s := range songs {
if _, done := result[i]; done {
continue
}
if s.MBID != "" {
mbids = append(mbids, s.MBID)
}
}
matches := map[string]model.MediaFile{}
if len(mbids) == 0 {
return matches, nil
return nil
}
res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
@ -230,52 +141,86 @@ func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, pri
},
})
if err != nil {
return matches, err
return err
}
byMBID := make(map[string]model.MediaFile, len(res))
for _, mf := range res {
if id := mf.MbzRecordingID; id != "" {
if _, ok := matches[id]; !ok {
matches[id] = mf
if _, ok := byMBID[id]; !ok {
byMBID[id] = mf
}
}
}
return matches, nil
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
}
// 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) {
// 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 {
var isrcs []string
for _, s := range songs {
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
for i, s := range songs {
if _, done := result[i]; done {
continue
}
if s.ISRC != "" {
isrcs = append(isrcs, s.ISRC)
}
}
matches := map[string]model.MediaFile{}
if len(isrcs) == 0 {
return matches, nil
return 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 matches, err
return err
}
byISRC := make(map[string]model.MediaFile, len(res))
for _, mf := range res {
for _, isrc := range mf.Tags.Values(model.TagISRC) {
if _, ok := matches[isrc]; !ok {
matches[isrc] = mf
if _, ok := byISRC[isrc]; !ok {
byISRC[isrc] = mf
}
}
}
return matches, nil
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
}
// songQuery represents a normalized query for matching a song to library tracks.
type songQuery struct {
title string
artist string
artistMBID string
artists []queryArtist
album string
albumMBID string
durationMs uint32
@ -286,11 +231,13 @@ type matchScore struct {
titleSimilarity float64
durationProximity float64
preferredMatch bool
albumSimilarity float64
specificityLevel int
artistOverlap int
albumSimilarity float64
}
// 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
@ -298,106 +245,313 @@ func (s matchScore) betterThan(other matchScore) bool {
if s.durationProximity != other.durationProximity {
return s.durationProximity > other.durationProximity
}
if s.preferredMatch != other.preferredMatch {
return s.preferredMatch
}
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
}
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 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.
// 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.
type sanitizedTrack struct {
mf *model.MediaFile
title string
artist string
album string
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)
}
func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack {
func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack {
return sanitizedTrack{
mf: mf,
title: str.SanitizeFieldForSorting(mf.Title),
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
album: str.SanitizeFieldForSorting(mf.Album),
mf: mf,
title: str.SanitizeFieldForSorting(mf.Title),
artist: str.SanitizeFieldForSortingNoArticle(mf.Artist),
album: str.SanitizeFieldForSorting(mf.Album),
artistIDs: artistIDs,
artistMBIDs: artistMBIDs,
}
}
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
// The track's title, artist, and album fields must be pre-sanitized.
// 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).
func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int {
if q.artistMBID != "" && q.albumMBID != "" &&
t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID {
return 5
}
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
}
// 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 map[string]model.MediaFile{}, nil
}
threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0
byArtist := map[string][]songQuery{}
for _, q := range queries {
if q.artist != "" {
byArtist[q.artist] = append(byArtist[q.artist], q)
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
}
}
return best
}
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 {
// 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)
if len(queries) == 0 {
return 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
}
sanitized := make([]sanitizedTrack, len(tracks))
for i := range tracks {
sanitized[i] = newSanitizedTrack(&tracks[i])
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
}
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
// 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)
}
}
// 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})
if err != nil {
return resolvedArtists{}, err
}
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)
}
}
}
}
return matches, nil
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",
})
}
// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration
@ -412,34 +566,32 @@ 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, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) {
func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) {
var bestMatch model.MediaFile
bestScore := matchScore{titleSimilarity: -1}
found := false
for _, t := range sanitizedTracks {
titleSim := similarityRatio(q.title, t.title)
preferStarred := conf.Server.Matcher.PreferStarred
for _, c := range candidates {
titleSim := similarityRatio(q.title, c.title)
if titleSim < threshold {
continue
}
var albumSim float64
if q.album != "" {
albumSim = similarityRatio(q.album, t.album)
albumSim = similarityRatio(q.album, c.album)
}
score := matchScore{
titleSimilarity: titleSim,
durationProximity: durationProximity(q.durationMs, t.mf.Duration),
preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf),
durationProximity: durationProximity(q.durationMs, c.mf.Duration),
preferredMatch: preferStarred && isPreferredTrack(c.mf),
albumSimilarity: albumSim,
specificityLevel: computeSpecificityLevel(q, t, threshold),
specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold),
artistOverlap: c.overlap,
}
if score.betterThan(bestScore) {
bestScore = score
bestMatch = *t.mf
bestMatch = *c.mf
found = true
}
}
@ -450,66 +602,34 @@ func isPreferredTrack(mf *model.MediaFile) bool {
return mf.Starred || mf.Rating >= 4
}
// 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
}
// 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 {
// 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]agents.Song, len(songs))
addedBy := make(map[string]int, len(songs))
for _, t := range songs {
for i, s := range songs {
if len(mfs) == count {
break
}
mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
mf, found := matches[i]
if !found {
continue
}
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
if t != prevSong {
if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
if !s.Equals(songs[prevIdx]) {
continue
}
} else {
addedBy[mf.ID] = t
addedBy[mf.ID] = i
}
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 {

View File

@ -1,6 +1,9 @@
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"
)
@ -51,3 +54,205 @@ 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}
}

File diff suppressed because it is too large Load Diff

View File

@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.OS.Containerized = consts.InContainer
// Install info
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".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)
if conf.Server.CacheFolder != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
data.FS.Data = getFSInfo(conf.Server.DataFolder.String())
if conf.Server.CacheFolder.String() != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String())
}
if conf.Server.Backup.Path != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
if conf.Server.Backup.Path.String() != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String())
}
// Config info
@ -223,6 +223,7 @@ 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

View File

@ -43,45 +43,46 @@ 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"`
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"`
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"`
} `json:"config"`
Plugins map[string]PluginInfo `json:"plugins,omitempty"`
}

View File

@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error {
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = 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))

View File

@ -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 int = 0
var retries = 0
for {
fileInfo, err := os.Stat(path)

View File

@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
}
err = json.Unmarshal(input, nsp)
if err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
line, col := getPositionFromOffset(input, syntaxErr.Offset)
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
}

View File

@ -113,6 +113,20 @@ var _ = Describe("parseNSP", func() {
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
})
It("rejects a NSP that mixes top-level 'any' and 'all' instead of silently dropping a group", func() {
nsp := `{
"name": "Overplayed Favorites",
"any": [{"inPlaylist": {"path": "most-played-favorites.nsp"}}],
"all": [{"notInPlaylist": {"path": "favorites-not-played-in-4-yrs.nsp"}}],
"sort": "playCount, lastPlayed"
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
Expect(err.Error()).To(And(ContainSubstring("all"), ContainSubstring("any")))
})
It("gracefully handles non-string name field", func() {
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original"}

View File

@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
newName := "Updated Name"
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() {
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})
@ -307,7 +302,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
@ -371,7 +366,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create a real image file on disk
imgDir := filepath.Join(tmpDir, "artwork", "playlist")

View File

@ -4,11 +4,13 @@ import (
"context"
"errors"
"reflect"
"strings"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
)
// --- REST adapter (follows Share/Library pattern) ---
@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
}
func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist))
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
// updatePlaylistEntity updates playlist metadata with permission checks.
// Used by the REST API wrapper.
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error {
//
// cols names the fields the client actually sent in the JSON body (extracted by
// rest.Put). When non-empty, fields outside cols are not considered changed and
// are left untouched — this prevents partial requests like bulk "Make Public"
// (body: {"public": true}) from wiping fields that just happen to be zero in
// the deserialized entity (see issue #5541). An empty cols means "treat the
// entity as a complete record" — preserved for callers that don't use the REST
// wrapper.
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
return err
}
}
sent := sentFields(cols)
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
if !usr.IsAdmin && ownerChanged {
return rest.ErrPermissionDenied
}
contentChanged := entity.Name != current.Name ||
entity.Comment != current.Comment ||
(entity.OwnerID != "" && entity.OwnerID != current.OwnerID) ||
!rulesEqual(current.Rules, entity.Rules)
nameChanged := sent("name") && entity.Name != current.Name
commentChanged := sent("comment") && entity.Comment != current.Comment
rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
if contentChanged {
if entity.OwnerID != "" {
current.OwnerID = entity.OwnerID
}
if nameChanged || commentChanged || ownerChanged || rulesChanged {
return s.applyContentUpdate(ctx, current, entity, sent,
nameChanged, commentChanged, ownerChanged, rulesChanged)
}
return s.applyFlagsOnly(ctx, current, entity, sent)
}
// applyContentUpdate handles updates that change at least one of name/comment/
// owner/rules. It goes through updateMetadata, which always bumps updatedAt
// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
// field is absent from the request OR present-but-unchanged (so updateMetadata
// skips them); publicPtr is nil only when public is absent from the request
// (an idempotent public value is still forwarded).
func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
) error {
if ownerChanged {
current.OwnerID = entity.OwnerID
}
if rulesChanged {
current.Rules = entity.Rules
if current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync
}
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
}
// Only sync/public changed — skip updatedAt so cover art URLs stay stable
var cols []string
if current.Path != "" && current.Sync != entity.Sync {
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync
cols = append(cols, "sync")
}
if current.Public != entity.Public {
var namePtr, commentPtr *string
var publicPtr *bool
if nameChanged {
namePtr = &entity.Name
}
if commentChanged {
commentPtr = &entity.Comment
}
if sent("public") {
publicPtr = &entity.Public
}
return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr)
}
// applyFlagsOnly handles updates that only toggle sync/public — skips
// updatedAt so cover art URLs stay stable.
func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist,
sent func(string) bool,
) error {
var updateCols []string
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync
updateCols = append(updateCols, "sync")
}
if sent("public") && current.Public != entity.Public {
current.Public = entity.Public
cols = append(cols, "public")
updateCols = append(updateCols, "public")
}
if len(cols) == 0 {
if len(updateCols) == 0 {
return nil
}
return s.ds.Playlist(ctx).Put(current, cols...)
return s.ds.Playlist(ctx).Put(current, updateCols...)
}
// sentFields returns a predicate that reports whether a JSON field was present
// in the request body. Matching is case-insensitive to mirror Go's json
// decoder, which populates struct fields from case-variant keys like
// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity
// as a full record" — every field is considered sent.
func sentFields(cols []string) func(string) bool {
if len(cols) == 0 {
return func(string) bool { return true }
}
set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} })
return func(field string) bool {
_, ok := set[strings.ToLower(field)]
return ok
}
}
func rulesEqual(a, b *criteria.Criteria) bool {

View File

@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() {
It("clears server-managed fields to prevent injection via REST API", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
now := time.Now()
pls := &model.Playlist{
Name: "Legit Playlist",
Comment: "A comment",
@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() {
Sync: true,
UploadedImage: "injected-image-path",
ExternalImageURL: "http://evil.example.com/ssrf",
EvaluatedAt: &now,
EvaluatedAt: new(time.Now()),
}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
@ -126,6 +125,25 @@ var _ = Describe("REST Adapter", func() {
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
DescribeTable("denies regular user from changing ownership under any case-variant JSON key",
func(colName string) {
// rest.Put's field-name extraction is case-sensitive, but Go's
// json decoder is case-insensitive on struct fields, so any
// {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates
// entity.OwnerID. sentFields normalizes both sides so the
// permission gate fires regardless of casing.
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{OwnerID: "other-user"}
err := repo.Update("pls-1", pls, colName)
Expect(err).To(Equal(rest.ErrPermissionDenied))
},
Entry("canonical camelCase", "ownerId"),
Entry("PascalCase", "OwnerId"),
Entry("all upper", "OWNERID"),
Entry("all lower", "ownerid"),
)
It("updates smart playlist rules", func() {
mockPlsRepo.Data["smart-1"] = &model.Playlist{
ID: "smart-1",
@ -219,6 +237,156 @@ var _ = Describe("REST Adapter", func() {
err := repo.Update("nonexistent", pls)
Expect(err).To(Equal(rest.ErrNotFound))
})
// Regression tests for #5541: partial REST updates (e.g. bulk "Make Public")
// must only touch the fields the client actually sent. The cols list from
// rest.Put names those fields; fields outside it must be left alone, even
// when the deserialized entity has zero values for them.
Context("with partial updates (cols)", func() {
BeforeEach(func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
mockPlsRepo.Data["partial"] = &model.Playlist{
ID: "partial",
Name: "Original Name",
Comment: "Original comment",
OwnerID: "user-1",
Public: false,
}
})
It("preserves name and comment when only public is sent (bulk Make Public)", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
Expect(mockPlsRepo.Last.Public).To(BeTrue())
})
It("preserves name when only sync is sent for a file-backed playlist", func() {
mockPlsRepo.Data["file-partial"] = &model.Playlist{
ID: "file-partial",
Name: "Keep Me",
OwnerID: "user-1",
Path: "/music/p.m3u",
Sync: true,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me"))
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
})
It("renames the playlist when only name is sent", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
Expect(mockPlsRepo.Last.Public).To(BeFalse())
})
It("clears the comment when an empty comment is sent explicitly", func() {
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Comment).To(BeEmpty())
Expect(mockPlsRepo.Last.Name).To(Equal("Original Name"))
})
It("updates rules-only on a smart playlist (Feishin-style edit)", func() {
mockPlsRepo.Data["smart-partial"] = &model.Playlist{
ID: "smart-partial",
Name: "Smart Original",
Comment: "smart comment",
OwnerID: "user-1",
Public: true,
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
}
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"}
err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original"))
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
Expect(mockPlsRepo.Last.Public).To(BeTrue())
})
It("updates name and rules together (smart-playlist Edit form)", func() {
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
ID: "smart-edit",
Name: "Smart Original",
Comment: "smart comment",
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
}
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"}
err := repo.Update("smart-edit",
&model.Playlist{Name: "Smart Renamed", Rules: newRules},
"name", "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed"))
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment"))
})
It("does not bump the saved rules on an idempotent rules-only PUT", func() {
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{
ID: "smart-idempotent",
Name: "Smart Idempotent",
OwnerID: "user-1",
Rules: rules,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
// Same rules sent back — rulesEqual should report no change and
// the request should no-op (no Put call).
sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened
})
It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() {
rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}
mockPlsRepo.Data["smart-public"] = &model.Playlist{
ID: "smart-public",
Name: "Smart Public",
OwnerID: "user-1",
Public: false,
Rules: rules,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("smart-public", &model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Public).To(BeTrue())
Expect(mockPlsRepo.Last.Rules).To(Equal(rules))
Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public"))
})
It("does not treat a missing ownerId as an ownership transfer attempt", func() {
// A non-admin user sending only {public:true} should not be blocked
// just because OwnerID is the zero value in the deserialized entity.
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Public: true}, "public")
Expect(err).ToNot(HaveOccurred())
})
It("matches cols case-insensitively (mirrors json decoder behavior)", func() {
// Go's json decoder populates struct fields from case-variant keys
// like {"Name":"x"}, but rest.Put's field-name extraction is
// case-sensitive. sentFields normalizes both sides so a request
// with {"Name":"Renamed"} is honored, not silently ignored.
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("Renamed"))
Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment"))
})
})
})
Describe("Delete", func() {

View File

@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// Loader is a function that loads a scrobbler by name.
@ -129,6 +130,14 @@ func (b *bufferedScrobbler) processQueue(ctx context.Context) bool {
}
func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool {
// Scrobbles are drained on a background context that no longer carries the
// request's authenticated user. Restore it from the buffered userId so that
// scrobblers relying on the user in the context (e.g. plugins) still get it.
if user, err := b.ds.User(ctx).Get(userId); err != nil {
log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err)
} else {
ctx = request.WithUser(ctx, *user)
}
buffer := b.ds.ScrobbleBuffer(ctx)
for {
entry, err := buffer.Next(b.service, userId)

View File

@ -20,8 +20,11 @@ var _ = Describe("BufferedScrobbler", func() {
BeforeEach(func() {
ctx = context.Background()
buffer = tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
ds = &tests.MockDataStore{
MockedScrobbleBuffer: buffer,
MockedUser: userRepo,
}
scr = &fakeScrobbler{Authorized: true}
bs = newBufferedScrobbler(ds, scr, "test")
@ -62,6 +65,16 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now))
})
It("restores the user in the context when draining buffered scrobbles", func() {
track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"}
scrobble := Scrobble{MediaFile: track, TimeStamp: time.Now()}
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
Expect(scr.GetUsername()).To(Equal("alice"))
})
It("stops the background goroutine when Stop is called", func() {
// Replace the real run method with one that signals when it exits
done := make(chan struct{})

View File

@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
if !params.IgnoreScrobble && player.ScrobbleEnabled &&
// NowPlaying gating, by design distinct from scrobble submission:
// - IgnoreScrobble=true -> still send NowPlaying (suppresses only the
// scrobble submission/play-count above), mirroring the legacy scrobble
// endpoint's submission=false behavior.
// - player.ScrobbleEnabled=false -> never send NowPlaying.
// External agents here are the active scrobblers (Last.fm, ListenBrainz, and
// scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying.
if player.ScrobbleEnabled &&
(params.State == StateStarting || params.State == StatePlaying) {
if info, err := p.playMap.Get(clientId); err == nil {
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
@ -484,3 +491,10 @@ func Register(name string, init Constructor) {
}
constructors[name] = init
}
// IsBuiltinScrobbler reports whether name belongs to a registered builtin
// scrobbler (e.g. "lastfm", "listenbrainz").
func IsBuiltinScrobbler(name string) bool {
_, ok := constructors[name]
return ok
}

View File

@ -104,6 +104,13 @@ var _ = Describe("PlayTracker", func() {
Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("IsBuiltinScrobbler", func() {
It("reports whether the name belongs to a registered builtin scrobbler", func() {
Expect(IsBuiltinScrobbler("fake")).To(BeTrue())
Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse())
})
})
Describe("GetNowPlaying", func() {
It("returns current playing music", func() {
track2 := track
@ -283,7 +290,7 @@ var _ = Describe("PlayTracker", func() {
Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix()))
})
It("does not record scrobble when history is disabled", func() {
@ -521,6 +528,7 @@ var _ = Describe("PlayTracker", func() {
})
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
fake.ScrobbleCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
@ -531,6 +539,7 @@ var _ = Describe("PlayTracker", func() {
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
})
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
@ -715,14 +724,14 @@ var _ = Describe("PlayTracker", func() {
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ignoreScrobble=true", func() {
It("still dispatches NowPlaying when ignoreScrobble=true", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("does NOT dispatch when ScrobbleEnabled=false", func() {
@ -1089,6 +1098,13 @@ func (f *fakeScrobbler) GetUserID() string {
return ""
}
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
@ -1120,6 +1136,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
f.userID.Store(&userId)
// Capture username from context (this is what plugin scrobblers do)
username, _ := request.UsernameFrom(ctx)
if username == "" {
if u, ok := request.UserFrom(ctx); ok {
username = u.UserName
}
}
if username != "" {
f.username.Store(&username)
}
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {
@ -1133,8 +1159,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession
if f.Error != nil {
return f.Error
}
uid := info.UserId
f.userID.Store(&uid)
f.userID.Store(new(info.UserId))
f.LastPlaybackReport.Store(&info)
return nil
}

View File

@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
share.LastVisitedAt = P(time.Now())
share.LastVisitedAt = new(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@ -95,10 +95,10 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
if err != nil {
return "", err

View File

@ -101,7 +101,7 @@ var _ = Describe("Sonic", func() {
provider := &mockProvider{
similarResults: []sonic.SimilarResult{
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85},
},
}
loader.names = []string{"test-plugin"}

View File

@ -17,6 +17,14 @@ type MusicFS interface {
ReadTags(path ...string) (map[string]metadata.Info, error)
}
// SymlinkResolverFS is an optional interface for MusicFS implementations backed by a real
// filesystem. ResolveSymlink resolves the whole symlink chain of the named entry at the OS
// level and returns the final target's path — including targets outside the FS root, which
// fs.ReadLink-based resolution cannot follow.
type SymlinkResolverFS interface {
ResolveSymlink(name string) (string, error)
}
// Watcher is a storage with the ability watch the FS and notify changes
type Watcher interface {
// Start starts a watcher on the whole FS and returns a channel to send detected changes.

View File

@ -54,12 +54,23 @@ func (s *localStorage) FS() (storage.MusicFS, error) {
if _, err := os.Stat(path); err != nil { //nolint:gosec
return nil, fmt.Errorf("%w: %s", err, path)
}
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil
return &localFS{FS: os.DirFS(path), extractor: s.extractor, root: path}, nil
}
type localFS struct {
fs.FS
extractor Extractor
root string
}
// ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the
// OS level, so links whose targets live outside the library folder (not reachable through
// the fs.FS abstraction) still resolve to their final target.
func (lfs *localFS) ResolveSymlink(name string) (string, error) {
if !fs.ValidPath(name) {
return "", &fs.PathError{Op: "resolvesymlink", Path: name, Err: fs.ErrInvalid}
}
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
}
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {

View File

@ -13,7 +13,6 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -45,16 +44,13 @@ var _ = Describe("LocalStorage", func() {
})
Describe("newLocalStorage", func() {
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
})
Context("with valid path", func() {
It("should create a localStorage instance with correct path", func() {
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
localStorage := storage.(*localStorage)
Expect(localStorage.u.Scheme).To(Equal("file"))
@ -94,10 +90,10 @@ var _ = Describe("LocalStorage", func() {
err = os.Symlink(realDir, linkDir)
Expect(err).ToNot(HaveOccurred())
u, err := url.Parse("file://" + linkDir)
u, err := storage.LocalPathToURL(linkDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
@ -110,10 +106,10 @@ var _ = Describe("LocalStorage", func() {
// Use a non-existent path to trigger symlink resolution failure
nonExistentPath := filepath.Join(tempDir, "non-existent")
u, err := url.Parse("file://" + nonExistentPath)
u, err := storage.LocalPathToURL(nonExistentPath)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
@ -137,7 +133,9 @@ var _ = Describe("LocalStorage", func() {
localStorage, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(localStorage.u.Path).To(Equal("C:/music"))
// newLocalStorage re-joins the drive letter (u.Host) with u.Path via
// filepath.Join, which yields an OS-native (backslash) path on Windows.
Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music")))
})
})
@ -159,10 +157,10 @@ var _ = Describe("LocalStorage", func() {
It("falls back to the default extractor instead of crashing", func() {
conf.Server.Scanner.Extractor = "nonexistent-extractor"
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
ls, ok := storage.(*localStorage)
Expect(ok).To(BeTrue())
Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor))
@ -171,16 +169,13 @@ var _ = Describe("LocalStorage", func() {
})
Describe("localStorage.FS", func() {
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
})
Context("with existing directory", func() {
It("should return a localFS instance", func() {
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
Expect(musicFS).ToNot(BeNil())
@ -193,10 +188,10 @@ var _ = Describe("LocalStorage", func() {
Context("with non-existent directory", func() {
It("should return an error", func() {
nonExistentPath := filepath.Join(tempDir, "non-existent")
u, err := url.Parse("file://" + nonExistentPath)
u, err := storage.LocalPathToURL(nonExistentPath)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
_, err = storage.FS()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(nonExistentPath))
@ -204,11 +199,82 @@ var _ = Describe("LocalStorage", func() {
})
})
Describe("localFS.ResolveSymlink", func() {
var musicFS storage.MusicFS
BeforeEach(func() {
if runtime.GOOS == "windows" {
Skip("symlink semantics")
}
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
musicFS, err = newLocalStorage(u).FS()
Expect(err).ToNot(HaveOccurred())
})
It("implements storage.SymlinkResolverFS", func() {
_, ok := musicFS.(storage.SymlinkResolverFS)
Expect(ok).To(BeTrue())
})
It("resolves a chain that leaves the library folder to its final target", func() {
outside, err := os.MkdirTemp("", "navidrome-symlink-outside-")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(outside) })
target := filepath.Join(outside, "final.txt")
Expect(os.WriteFile(target, []byte("data"), 0600)).To(Succeed())
mid := filepath.Join(outside, "mid.wav")
Expect(os.Symlink(target, mid)).To(Succeed())
Expect(os.Symlink(mid, filepath.Join(tempDir, "link.wav"))).To(Succeed())
resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("link.wav")
Expect(err).ToNot(HaveOccurred())
expected, err := filepath.EvalSymlinks(target)
Expect(err).ToNot(HaveOccurred())
Expect(resolved).To(Equal(expected))
})
It("resolves entries in subfolders (slash-separated fs paths)", func() {
Expect(os.MkdirAll(filepath.Join(tempDir, "sub"), 0755)).To(Succeed())
target := filepath.Join(tempDir, "real.mp3")
Expect(os.WriteFile(target, []byte("audio"), 0600)).To(Succeed())
Expect(os.Symlink(target, filepath.Join(tempDir, "sub", "link.mp3"))).To(Succeed())
resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("sub/link.mp3")
Expect(err).ToNot(HaveOccurred())
expected, err := filepath.EvalSymlinks(target)
Expect(err).ToNot(HaveOccurred())
Expect(resolved).To(Equal(expected))
})
It("returns an error for a broken symlink", func() {
Expect(os.Symlink(filepath.Join(tempDir, "missing.mp3"), filepath.Join(tempDir, "broken.mp3"))).To(Succeed())
_, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("broken.mp3")
Expect(err).To(HaveOccurred())
})
It("rejects names that are not valid fs paths", func() {
for _, name := range []string{"../outside.mp3", "/etc/hosts", "sub/../../outside.mp3", ""} {
_, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink(name)
Expect(err).To(MatchError(fs.ErrInvalid), name)
}
})
It("returns an error for a symlink loop", func() {
Expect(os.Symlink(filepath.Join(tempDir, "loop2.mp3"), filepath.Join(tempDir, "loop1.mp3"))).To(Succeed())
Expect(os.Symlink(filepath.Join(tempDir, "loop1.mp3"), filepath.Join(tempDir, "loop2.mp3"))).To(Succeed())
_, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("loop1.mp3")
Expect(err).To(HaveOccurred())
})
})
Describe("localFS.ReadTags", func() {
var testFile string
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
// Create a test file
testFile = filepath.Join(tempDir, "test.mp3")
err := os.WriteFile(testFile, []byte("test data"), 0600)
@ -235,9 +301,9 @@ var _ = Describe("LocalStorage", func() {
testExtractor.results["test.mp3"] = expectedInfo
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
@ -259,9 +325,9 @@ var _ = Describe("LocalStorage", func() {
testExtractor.results["test.mp3"] = incompleteInfo
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
@ -288,9 +354,9 @@ var _ = Describe("LocalStorage", func() {
testExtractor.results["non-existent.mp3"] = incompleteInfo
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
@ -303,9 +369,9 @@ var _ = Describe("LocalStorage", func() {
It("should return the extractor error", func() {
testExtractor.err = &extractorError{message: "extractor failed"}
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
@ -334,9 +400,9 @@ var _ = Describe("LocalStorage", func() {
testExtractor.results["test.mp3"] = info1
testExtractor.results["test2.mp3"] = info2
u, err := url.Parse("file://" + tempDir)
u, err := storage.LocalPathToURL(tempDir)
Expect(err).ToNot(HaveOccurred())
storage := newLocalStorage(*u)
storage := newLocalStorage(u)
musicFS, err := storage.FS()
Expect(err).ToNot(HaveOccurred())
@ -390,9 +456,8 @@ var _ = Describe("LocalStorage", func() {
Describe("Storage registration", func() {
It("should register localStorage for file scheme", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)")
// This tests the init() function indirectly
storage, err := storage.For("file://" + tempDir)
storage, err := storage.For(tempDir)
Expect(err).ToNot(HaveOccurred())
Expect(storage).To(BeAssignableToTypeOf(&localStorage{}))
})

View File

@ -25,6 +25,27 @@ func Register(schema string, c constructor) {
registry[schema] = c
}
// LocalPathToURL converts a bare OS filesystem path into an absolute file:// URL,
// applying the same slash-normalisation and per-component escaping the scanner
// relies on. It is the single source of truth for how a local path becomes a
// storage URL, shared by For and by storage tests.
func LocalPathToURL(osPath string) (url.URL, error) {
abs, _ := filepath.Abs(osPath)
abs = filepath.ToSlash(abs)
// Properly escape each path component using URL standards
pathParts := strings.Split(abs, "/")
escapedParts := slice.Map(pathParts, func(s string) string {
return url.PathEscape(s)
})
u, err := url.Parse(LocalSchemaID + "://" + strings.Join(escapedParts, "/"))
if err != nil {
return url.URL{}, err
}
return *u, nil
}
// For returns a Storage implementation for the given URI.
// It uses the schema part of the URI to find the correct registered
// Storage constructor.
@ -34,24 +55,22 @@ func For(uri string) (Storage, error) {
defer lock.RUnlock()
parts := strings.Split(uri, "://")
var u *url.URL
// Paths without schema are treated as file:// and use the default LocalStorage implementation
if len(parts) < 2 {
uri, _ = filepath.Abs(uri)
uri = filepath.ToSlash(uri)
// Properly escape each path component using URL standards
pathParts := strings.Split(uri, "/")
escapedParts := slice.Map(pathParts, func(s string) string {
return url.PathEscape(s)
})
uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/")
parsed, err := LocalPathToURL(uri)
if err != nil {
return nil, err
}
u = &parsed
} else {
parsed, err := url.Parse(uri)
if err != nil {
return nil, err
}
u = parsed
}
u, err := url.Parse(uri)
if err != nil {
return nil, err
}
c, ok := registry[u.Scheme]
if !ok {
return nil, errors.New("schema '" + u.Scheme + "' not registered")

View File

@ -4,6 +4,7 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/navidrome/navidrome/tests"
@ -83,6 +84,43 @@ var _ = Describe("Storage", func() {
Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"),
)
})
Describe("LocalPathToURL", func() {
It("builds a file:// URL from an absolute path", func() {
u, err := LocalPathToURL("/tmp/music")
Expect(err).ToNot(HaveOccurred())
Expect(u.Scheme).To(Equal("file"))
Expect(u.Path).To(Equal("/tmp/music"))
})
It("escapes special characters and decodes them back in Path", func() {
u, err := LocalPathToURL("/tmp/Song #1 & More?.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(u.Path).To(Equal("/tmp/Song #1 & More?.mp3"))
})
It("produces the same url.URL that For uses for a bare path", func() {
registry = map[string]constructor{}
Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} })
s, err := For("/tmp/music")
Expect(err).ToNot(HaveOccurred())
direct, err := LocalPathToURL("/tmp/music")
Expect(err).ToNot(HaveOccurred())
Expect(s.(*fakeLocalStorage).u).To(Equal(direct))
})
// On Windows, library paths are drive-lettered (e.g. C:\Music). This
// exercises the real conversion to confirm a drive-letter path yields a
// usable file:// URL on the actual platform (no-op on Unix).
It("handles a Windows drive-letter path", func() {
if runtime.GOOS != "windows" {
Skip("Windows-specific path handling")
}
u, err := LocalPathToURL(`C:\Music`)
Expect(err).ToNot(HaveOccurred())
Expect(u.Scheme).To(Equal("file"))
})
})
})
type fakeLocalStorage struct {

View File

@ -43,14 +43,17 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int {
return sampleRate
}
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
// what ffmpeg produces). For other codecs, returns the depth unchanged.
func normalizeSourceBitDepth(bitDepth int, codec string) int {
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy
// targets (they have no PCM bit depth), otherwise the source depth, with DSD
// adjusted to the 24-bit PCM that ffmpeg produces.
func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int {
if !targetIsLossless {
return 0
}
if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 {
return 24
}
return bitDepth
return srcBitDepth
}
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gg"
)
const fallbackBitrate = 256 // kbps
@ -142,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta
sd.Codec = mf.AudioCodec()
sd.Bitrate = mf.BitRate
sd.SampleRate = mf.SampleRate
sd.BitDepth = mf.BitDepth
sd.BitDepth = gg.V(mf.BitDepth)
sd.Channels = mf.Channels
}
sd.IsLossless = isLosslessFormat(sd.Codec)
@ -268,7 +269,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
Codec: strings.ToLower(profile.AudioCodec),
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
Channels: src.Channels,
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless),
IsLossless: targetIsLossless,
}
if ts.Codec == "" {

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -23,7 +24,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile {
Codec: mf.AudioCodec(),
BitRate: mf.BitRate,
SampleRate: mf.SampleRate,
BitDepth: mf.BitDepth,
BitDepth: gg.V(mf.BitDepth),
Channels: mf.Channels,
}
data, _ := json.Marshal(probe)
@ -243,7 +244,7 @@ var _ = Describe("Decider", func() {
Context("Transcoding", func() {
It("selects transcoding when direct play isn't possible", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256, // kbps
DirectPlayProfiles: []DirectPlayProfile{
@ -278,7 +279,7 @@ var _ = Describe("Decider", func() {
})
It("uses default bitrate when client doesn't specify", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "mp3", Protocol: ProtocolHTTP},
@ -331,7 +332,7 @@ var _ = Describe("Decider", func() {
})
It("selects first valid transcoding profile in order", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
DirectPlayProfiles: []DirectPlayProfile{
@ -351,7 +352,7 @@ var _ = Describe("Decider", func() {
Context("Lossless to lossless transcoding", func() {
It("allows lossless to lossless when samplerate needs downsampling", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)})
ci := &ClientInfo{
MaxAudioBitrate: 1000,
DirectPlayProfiles: []DirectPlayProfile{
@ -369,7 +370,7 @@ var _ = Describe("Decider", func() {
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
// Transcoding to mp3 (lossy) should result in IsLossless=false.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -526,7 +527,7 @@ var _ = Describe("Decider", func() {
})
It("rejects direct play due to samplerate limitation", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
@ -573,7 +574,7 @@ var _ = Describe("Decider", func() {
})
It("applies channel limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -596,7 +597,7 @@ var _ = Describe("Decider", func() {
})
It("applies samplerate limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -619,7 +620,7 @@ var _ = Describe("Decider", func() {
})
It("applies bitdepth limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -642,7 +643,7 @@ var _ = Describe("Decider", func() {
})
It("preserves source bit depth when no limitation applies", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -655,8 +656,46 @@ var _ = Describe("Decider", func() {
Expect(decision.TargetBitDepth).To(Equal(24))
})
It("omits bit depth when transcoding to a lossy format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
Expect(decision.TargetBitDepth).To(BeZero())
})
It("ignores audioBitdepth limitation when transcoding to a lossy format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
},
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "opus",
Limitations: []Limitation{
{Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true},
},
},
},
}
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
})
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -680,7 +719,7 @@ var _ = Describe("Decider", func() {
Context("DSD sample rate conversion", func() {
It("converts DSD sample rate to PCM-equivalent in decision", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -694,13 +733,13 @@ var _ = Describe("Decider", func() {
// DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
Expect(decision.TargetSampleRate).To(Equal(48000))
// DSD 1-bit → 24-bit PCM
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
Expect(decision.TargetBitDepth).To(Equal(24))
// MP3 is lossy: no bit depth on the transcoded stream
Expect(decision.TranscodeStream.BitDepth).To(BeZero())
Expect(decision.TargetBitDepth).To(BeZero())
})
It("converts DSD sample rate for FLAC target without codec limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -719,7 +758,7 @@ var _ = Describe("Decider", func() {
})
It("applies codec profile limit to DSD-converted FLAC sample rate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -746,7 +785,7 @@ var _ = Describe("Decider", func() {
})
It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -775,7 +814,7 @@ var _ = Describe("Decider", func() {
// Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
// The decider must clamp to the codec's hard limit even when no
// transcoding profile MaxAudioChannels is configured.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -791,7 +830,7 @@ var _ = Describe("Decider", func() {
})
It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -806,7 +845,7 @@ var _ = Describe("Decider", func() {
})
It("applies the codec clamp when the profile limit is looser", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -821,7 +860,7 @@ var _ = Describe("Decider", func() {
})
It("passes channels through unchanged for codecs with no hard limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -840,7 +879,7 @@ var _ = Describe("Decider", func() {
Context("Probe-based lossless detection", func() {
It("uses probe codec name for lossless detection", func() {
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}
probe := ffmpeg.AudioProbeResult{
Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
}
@ -884,7 +923,7 @@ var _ = Describe("Decider", func() {
Context("Opus fixed sample rate", func() {
It("sets Opus output to 48000Hz regardless of input", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 128,
TranscodingProfiles: []Profile{
@ -901,7 +940,7 @@ var _ = Describe("Decider", func() {
})
It("sets Opus output to 48000Hz even for 96kHz input", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 128,
TranscodingProfiles: []Profile{
@ -917,7 +956,7 @@ var _ = Describe("Decider", func() {
Context("Container vs format separation", func() {
It("preserves mp4 container when falling back to aac format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
@ -935,7 +974,7 @@ var _ = Describe("Decider", func() {
})
It("uses container as format when container matches transcoding config", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
@ -952,7 +991,7 @@ var _ = Describe("Decider", func() {
Context("MP3 max sample rate", func() {
It("caps sample rate at 48000 for MP3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -966,7 +1005,7 @@ var _ = Describe("Decider", func() {
})
It("preserves sample rate at 44100 for MP3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -982,7 +1021,7 @@ var _ = Describe("Decider", func() {
Context("AAC max sample rate", func() {
It("caps sample rate at 96000 for AAC", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -1025,7 +1064,7 @@ var _ = Describe("Decider", func() {
Context("Source stream details", func() {
It("populates source stream correctly with kbps bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
@ -1058,7 +1097,7 @@ var _ = Describe("Decider", func() {
})
It("ignores player MaxBitRate in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
@ -1074,7 +1113,7 @@ var _ = Describe("Decider", func() {
Context("Format-aware default bitrate", func() {
It("uses opus default bitrate from DB", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
@ -1087,7 +1126,7 @@ var _ = Describe("Decider", func() {
})
It("uses aac default bitrate from DB", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},

View File

@ -12,7 +12,7 @@ import (
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
// into a ClientInfo for use with MakeDecision.
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo {
ci := &ClientInfo{Name: "legacy"}
// Determine target format for transcoding
@ -22,6 +22,10 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int
targetFormat = reqFormat
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
targetFormat = conf.Server.DefaultDownsamplingFormat
case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
// Server-side player MaxBitRate alone forces downsampling, even when the
// client sent no format/bitrate params (issue #5583, legacy /stream path).
targetFormat = conf.Server.DefaultDownsamplingFormat
}
if targetFormat != "" {
@ -63,15 +67,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
return req
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
playerMaxBitRate := 0
if player, ok := request.PlayerFrom(ctx); ok {
playerMaxBitRate = player.MaxBitRate
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate)
// Apply server-side player transcoding override before making the decision
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
} else if player, ok := request.PlayerFrom(ctx); ok {
modified := *clientInfo
if modified.CapBitrate(player.MaxBitRate) {
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}

View File

@ -21,7 +21,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("sets transcoding profile for explicit format without bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 0)
ci := buildLegacyClientInfo(mf, "mp3", 0, 0)
Expect(ci.Name).To(Equal("legacy"))
Expect(ci.TranscodingProfiles).To(HaveLen(1))
@ -34,7 +34,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
ci := buildLegacyClientInfo(mf, "opus", 0)
ci := buildLegacyClientInfo(mf, "opus", 0, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
@ -42,7 +42,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("adds direct play profile when explicit format matches source format", func() {
ci := buildLegacyClientInfo(mf, "flac", 0)
ci := buildLegacyClientInfo(mf, "flac", 0, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
@ -52,7 +52,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 192)
ci := buildLegacyClientInfo(mf, "mp3", 192, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
@ -63,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("returns direct play profile when no format and no bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 0)
ci := buildLegacyClientInfo(mf, "", 0, 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
@ -77,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 128)
ci := buildLegacyClientInfo(mf, "", 128, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
@ -91,7 +91,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("returns direct play when bitrate >= source bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 960)
ci := buildLegacyClientInfo(mf, "", 960, 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
@ -100,6 +100,51 @@ var _ = Describe("buildLegacyClientInfo", func() {
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.MaxAudioBitrate).To(BeZero())
})
It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 0, 256)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
})
It("does not downsample when player MaxBitRate is >= source bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 0, 960)
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
})
It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
ci := buildLegacyClientInfo(mf, "", 0, 256)
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
})
It("prefers explicit request format over player cap", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "mp3", 0, 256)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
})
})
var _ = Describe("ResolveRequest", func() {
@ -138,7 +183,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("transcodes to requested format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
@ -147,7 +192,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("transcodes to requested format with bitrate limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
@ -169,7 +214,7 @@ var _ = Describe("ResolveRequest", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 128, 0)
@ -179,7 +224,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("passes offset through", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
@ -259,7 +304,7 @@ var _ = Describe("ResolveRequest", func() {
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
@ -270,7 +315,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decider := svc.(*deciderService)
@ -289,6 +334,33 @@ var _ = Describe("ResolveRequest", func() {
Expect(req.Format).To(Equal("raw"))
})
It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("opus"))
Expect(req.BitRate).To(Equal(256))
})
It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("fallback for unknown format", func() {
@ -332,7 +404,7 @@ var _ = Describe("ResolveRequest", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)

135
core/stream/limiter.go Normal file
View File

@ -0,0 +1,135 @@
package stream
import (
"context"
"errors"
"io"
"sync"
"sync/atomic"
)
// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the
// configured concurrency cap has been reached. Callers should translate this
// into an HTTP 429 response so well-behaved clients back off and retry.
var ErrTooManyTranscodes = errors.New("too many concurrent transcodes")
// RetryAfterSeconds is the value returned in the HTTP Retry-After header when
// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well
// within this window, so retrying after this delay typically succeeds.
const RetryAfterSeconds = 5
// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces
// both a global cap (to protect the host from process exhaustion) and an optional
// per-user cap (to keep one client from starving the others). Acquire never
// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately.
type TranscodeLimiter interface {
// Acquire reserves a slot for the given user. On success it returns a release
// function that must be called exactly once when the transcode is done.
// Calling release more than once is safe and idempotent.
Acquire(ctx context.Context, user string) (release func(), err error)
// Enabled reports whether the limiter actually enforces any cap. Callers
// can use it to decide whether to bind ffmpeg's lifetime to the request
// context so disconnects free slots quickly, rather than letting the
// process drain to completion in the background.
Enabled() bool
}
// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is
// independent: a value of zero or less disables that cap. When both caps are
// disabled the limiter is a no-op.
func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter {
if maxConcurrent <= 0 && maxPerUser <= 0 {
return noopLimiter{}
}
l := &transcodeLimiter{maxPerUser: maxPerUser}
if maxConcurrent > 0 {
l.global = make(chan struct{}, maxConcurrent)
}
if maxPerUser > 0 {
l.perUser = make(map[string]int)
}
return l
}
// releasingReadCloser wraps an io.ReadCloser so that closing it also releases
// the limiter slot exactly once. release must be the function returned by
// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too.
type releasingReadCloser struct {
io.ReadCloser
release func()
}
func (r *releasingReadCloser) Close() error {
err := r.ReadCloser.Close()
r.release()
return err
}
type noopLimiter struct{}
func (noopLimiter) Acquire(context.Context, string) (func(), error) {
return func() {}, nil
}
func (noopLimiter) Enabled() bool { return false }
type transcodeLimiter struct {
maxPerUser int
global chan struct{}
mu sync.Mutex
perUser map[string]int
}
func (*transcodeLimiter) Enabled() bool { return true }
func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) {
// Reserve a per-user slot first so a noisy user can't burn through
// global slots only to be rejected later. An empty user key means
// "anonymous" (e.g. public share viewers); we skip the per-user cap
// entirely so unrelated anonymous clients do not share a bucket.
perUserActive := l.maxPerUser > 0 && user != ""
if perUserActive {
l.mu.Lock()
if l.perUser[user] >= l.maxPerUser {
l.mu.Unlock()
return nil, ErrTooManyTranscodes
}
l.perUser[user]++
l.mu.Unlock()
}
if l.global != nil {
select {
case l.global <- struct{}{}:
default:
if perUserActive {
l.releasePerUser(user)
}
return nil, ErrTooManyTranscodes
}
}
var released atomic.Bool
return func() {
if !released.CompareAndSwap(false, true) {
return
}
if l.global != nil {
<-l.global
}
if perUserActive {
l.releasePerUser(user)
}
}, nil
}
func (l *transcodeLimiter) releasePerUser(user string) {
l.mu.Lock()
defer l.mu.Unlock()
l.perUser[user]--
if l.perUser[user] <= 0 {
delete(l.perUser, user)
}
}

186
core/stream/limiter_test.go Normal file
View File

@ -0,0 +1,186 @@
package stream_test
import (
"context"
"errors"
"sync"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TranscodeLimiter", func() {
ctx := log.NewContext(context.TODO())
Describe("Disabled (both caps <= 0)", func() {
It("never blocks and never returns ErrTooManyTranscodes", func() {
lim := stream.NewTranscodeLimiter(0, 0)
for range 100 {
rel, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
Expect(rel).ToNot(BeNil())
}
})
})
Describe("Per-user cap only (no global cap)", func() {
It("still enforces the per-user limit when MaxConcurrent is disabled", func() {
lim := stream.NewTranscodeLimiter(0, 2)
rel1, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
// Other users have their own buckets.
rel3, err := lim.Acquire(ctx, "bob")
Expect(err).ToNot(HaveOccurred())
rel1()
_, err = lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2()
rel3()
})
})
Describe("Global cap", func() {
It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
lim := stream.NewTranscodeLimiter(2, 0)
rel1, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "bob")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "carol")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
rel1()
_, err = lim.Acquire(ctx, "carol")
Expect(err).ToNot(HaveOccurred())
rel2()
})
It("releases a slot only once even if release is called multiple times", func() {
lim := stream.NewTranscodeLimiter(1, 0)
rel, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel()
rel()
rel()
// After releases, exactly one slot should be available.
_, err = lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
})
})
Describe("Per-user cap", func() {
It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() {
lim := stream.NewTranscodeLimiter(10, 2)
rel1, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
// A different user is unaffected.
rel3, err := lim.Acquire(ctx, "bob")
Expect(err).ToNot(HaveOccurred())
rel1()
_, err = lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
rel2()
rel3()
})
It("skips the per-user cap for anonymous users (empty key)", func() {
// Anonymous requests (e.g. public share viewers) deliberately
// bypass the per-user cap so unrelated anonymous clients are not
// collapsed into a single shared bucket. The global cap remains
// the only ceiling on anonymous traffic.
lim := stream.NewTranscodeLimiter(10, 1)
rels := make([]func(), 0, 5)
for range 5 {
rel, err := lim.Acquire(ctx, "")
Expect(err).ToNot(HaveOccurred())
rels = append(rels, rel)
}
for _, rel := range rels {
rel()
}
})
It("still applies the global cap to anonymous users", func() {
lim := stream.NewTranscodeLimiter(2, 1)
rel1, err := lim.Acquire(ctx, "")
Expect(err).ToNot(HaveOccurred())
rel2, err := lim.Acquire(ctx, "")
Expect(err).ToNot(HaveOccurred())
_, err = lim.Acquire(ctx, "")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
rel1()
rel2()
})
})
Describe("Concurrent safety", func() {
It("survives parallel Acquire/release with consistent counts", func() {
lim := stream.NewTranscodeLimiter(5, 0)
var wg sync.WaitGroup
var acquired int64
var rejected int64
var mu sync.Mutex
for i := range 50 {
wg.Add(1)
go func(i int) {
defer wg.Done()
rel, err := lim.Acquire(ctx, "alice")
mu.Lock()
if err == nil {
acquired++
mu.Unlock()
rel()
} else {
rejected++
mu.Unlock()
}
_ = i
}(i)
}
wg.Wait()
Expect(acquired + rejected).To(Equal(int64(50)))
// After all releases, all 5 slots should be free again.
for range 5 {
_, err := lim.Acquire(ctx, "alice")
Expect(err).ToNot(HaveOccurred())
}
_, err := lim.Acquire(ctx, "alice")
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
})
})
})

View File

@ -2,6 +2,7 @@ package stream
import (
"context"
"errors"
"fmt"
"io"
"mime"
@ -28,13 +29,19 @@ type MediaStreamer interface {
type TranscodingCache cache.FileCache
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
return &mediaStreamer{
ds: ds,
transcoder: t,
cache: cache,
limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
}
}
type mediaStreamer struct {
ds model.DataStore
transcoder ffmpeg.FFmpeg
cache cache.FileCache
limiter TranscodeLimiter
}
type streamJob struct {
@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
// Rate-limit rejections are already logged at warn level by the
// producer; treating them as cache failures here would both
// double-log and mask actual cache problems.
if !errors.Is(err, ErrTooManyTranscodes) {
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
}
return nil, err
}
cached = r.Cached
@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache {
return nil, os.ErrInvalid
}
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
// This is where we decide whether transcoding processes should be cancellable or not.
release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
if err != nil {
log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
"id", job.mf.ID, "user", userName(ctx),
"maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
"maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
return nil, err
}
// Choose the context that drives the ffmpeg process.
//
// When the limiter is enabled, force the request context so a
// client disconnect cancels ffmpeg and frees the slot promptly.
// Otherwise a client could open many transcodes, disconnect
// immediately, and still leave the configured cap's worth of
// ffmpeg processes draining in the background — which is exactly
// the DoS the limiter is meant to prevent.
//
// When the limiter is disabled, preserve the legacy behavior
// governed by Transcoding.EnableCancellation so unchanged configs
// keep their previous observable behavior.
var transcodingCtx context.Context
if conf.Server.EnableTranscodingCancellation {
// Use the request context directly, allowing cancellation when client disconnects
if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
transcodingCtx = ctx
} else {
// Use background context with request values preserved.
// This prevents cancellation but maintains request metadata (user, client, etc.)
transcodingCtx = request.AddValues(context.Background(), ctx)
}
@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache {
Offset: job.offset,
})
if err != nil {
release()
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
// Tie the slot to the ffmpeg process: copyAndClose calls Close
// on this reader after io.Copy returns, which is exactly when
// ffmpeg has exited (either EOF or context cancellation).
return &releasingReadCloser{ReadCloser: out, release: release}, nil
})
}
@ -255,3 +287,16 @@ func userName(ctx context.Context) string {
return user.UserName
}
}
// limiterKey returns the per-user bucket key used by the transcode limiter.
// For anonymous requests (e.g. public shares) it returns the empty string,
// which signals the limiter to skip the per-user cap entirely — otherwise
// every anonymous viewer of a public share would collide on the same key
// and starve each other within MaxConcurrentPerUser slots. The global cap
// still applies and remains the protection against runaway anonymous load.
func limiterKey(ctx context.Context) string {
if user, ok := request.UserFrom(ctx); ok {
return user.UserName
}
return ""
}

View File

@ -2,6 +2,7 @@ package stream_test
import (
"context"
"errors"
"io"
"os"
@ -10,6 +11,7 @@ import (
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -23,7 +25,8 @@ var _ = Describe("MediaStreamer", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
cacheDir, _ := os.MkdirTemp("", "file_caches")
conf.Server.CacheFolder = conf.NewDir(cacheDir)
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
@ -34,7 +37,7 @@ var _ = Describe("MediaStreamer", func() {
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
_ = os.RemoveAll(conf.Server.CacheFolder.String())
})
Context("NewStream", func() {
@ -60,6 +63,70 @@ var _ = Describe("MediaStreamer", func() {
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() {
// Use an ffmpeg whose Read blocks indefinitely so the cache's
// background copy can't drain the source and release the slot —
// keeping the single transcode slot pinned for this test.
pr, pw := io.Pipe()
DeferCleanup(func() { _ = pw.Close() })
blockingFFmpeg := tests.NewMockFFmpeg("")
blockingFFmpeg.Reader = pr
conf.Server.Transcoding.MaxConcurrent = 1
conf.Server.Transcoding.MaxConcurrentPerUser = 0
tightCache := stream.NewTranscodingCache()
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache)
userCtx := request.WithUsername(ctx, "alice")
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
Expect(err).ToNot(HaveOccurred())
defer s1.Close()
// Different cache key so it doesn't dedupe with the first request.
_, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue())
})
It("releases the slot once the stream is closed", func() {
conf.Server.Transcoding.MaxConcurrent = 1
conf.Server.Transcoding.MaxConcurrentPerUser = 0
tightCache := stream.NewTranscodingCache()
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
userCtx := request.WithUsername(ctx, "alice")
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
Expect(err).ToNot(HaveOccurred())
_, _ = io.ReadAll(s1)
_ = s1.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
// Slot should now be free for a different transcode.
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96})
Expect(err).ToNot(HaveOccurred())
defer s2.Close()
})
It("does not consume a slot for raw streams", func() {
conf.Server.Transcoding.MaxConcurrent = 1
conf.Server.Transcoding.MaxConcurrentPerUser = 0
tightCache := stream.NewTranscodingCache()
Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue())
tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache)
userCtx := request.WithUsername(ctx, "alice")
// First, saturate the single transcode slot.
s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64})
Expect(err).ToNot(HaveOccurred())
defer s1.Close()
// Raw stream must still succeed.
s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"})
Expect(err).ToNot(HaveOccurred())
defer s2.Close()
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())

View File

@ -92,17 +92,10 @@ func paramsFromToken(token jwt.Token) (*params, error) {
return &p, nil
}
// getIntClaim extracts an int claim from a JWT token, handling the case where
// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a
// parsed token are always deserialized as float64, regardless of the type used
// when encoding.
func getIntClaim(token jwt.Token, key string) int {
var v int
if err := token.Get(key, &v); err == nil {
return v
}
var v64 int64
if err := token.Get(key, &v64); err == nil {
return int(v64)
}
var f float64
if err := token.Get(key, &f); err == nil {
return int(f)

View File

@ -40,6 +40,50 @@ type ClientInfo struct {
CodecProfiles []CodecProfile
}
// CapBitrate lowers the client's declared audio bitrate limits to maxKbps,
// never raising them. A zero limit means "unlimited" and is set to maxKbps.
// Returns true if anything changed. No-op when maxKbps <= 0.
func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
if maxKbps <= 0 {
return false
}
changed := false
if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate {
ci.MaxAudioBitrate = maxKbps
changed = true
}
if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate {
ci.MaxTranscodingAudioBitrate = maxKbps
changed = true
}
return changed
}
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
// direct play, but only if the client already declares a profile for that
// format. All matching profiles are kept so negotiation can still pick among
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
// unsupported.
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
if targetFormat == "" {
return false
}
var matched []Profile
for i := range ci.TranscodingProfiles {
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
// target_format) still matches a resolved "opus" profile.
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
matched = append(matched, ci.TranscodingProfiles[i])
}
}
if len(matched) == 0 {
return false
}
ci.TranscodingProfiles = matched
ci.DirectPlayProfiles = nil
return true
}
// DirectPlayProfile describes a format the client can play directly
type DirectPlayProfile struct {
Containers []string

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