Merge branch 'master' into feat/support-playlist-paths

# Conflicts:
#	model/playlist.go
#	model/playlist_test.go
This commit is contained in:
David 2026-07-25 14:08:37 -05:00
commit a7cd940d55
276 changed files with 17908 additions and 1029 deletions

6
.github/FUNDING.yml vendored
View File

@ -1,10 +1,10 @@
# These are supported funding model platforms
github: deluan
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: deluan
github: deluan
open_collective: navidrome
liberapay: deluan
patreon: # Replace with a single Patreon username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
issuehunt: # Replace with a single IssueHunt username

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

@ -96,6 +96,27 @@ 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
@ -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
@ -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 ]
@ -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:

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"

View File

@ -69,20 +69,15 @@ RUN --mount=type=bind,source=. \
set -e
xx-go --wrap
export CGO_ENABLED=1
# Native libwebp (gen2brain/webp) uses ebitengine/purego reverse callbacks,
# which purego does not support on 32-bit ARM or x86 and crash with a SIGSEGV
# (issue #5597). Build those arches with the "nodynamic" tag so gen2brain/webp
# is WASM-only and never links the purego path. 64-bit arches keep native libwebp.
BUILD_TAGS=netgo,sqlite_fts5
if [ "$(xx-info arch)" = "arm" ] || [ "$(xx-info arch)" = "386" ]; then
BUILD_TAGS=${BUILD_TAGS},nodynamic
fi
BUILD_TAGS=$(./release/build-tags.sh)
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
go build -tags=${BUILD_TAGS} -ldflags="-w -s \
go build -tags="${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; }
@ -116,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.
@ -129,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

View File

@ -1,10 +1,12 @@
package deezer
import (
"cmp"
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"github.com/navidrome/navidrome/conf"
@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
}
}
// If the first one has the same name, that's the one
if !strings.EqualFold(artists[0].Name, name) {
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
// ahead of non-matches, prefer an exact-case match, then the most fans.
rank := func(a Artist) int {
switch {
case a.Name == name:
return 2
case strings.EqualFold(a.Name, name):
return 1
default:
return 0
}
}
slices.SortFunc(artists, func(a, b Artist) int {
return cmp.Or(
cmp.Compare(rank(b), rank(a)),
cmp.Compare(b.NbFan, a.NbFan),
cmp.Compare(a.ID, b.ID),
)
})
best := artists[0]
if !strings.EqualFold(best.Name, name) {
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
return &artists[0], err
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
return new(best), nil
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {

View File

@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() {
})
})
Describe("searchArtist", func() {
var agent *deezerAgent
var httpClient *fakeHttpClient
BeforeEach(func() {
httpClient = &fakeHttpClient{}
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
}
})
It("picks the exact-name match with the most fans when several share the name", func() {
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":61045802,"name":"Queen","nb_fan":75},
{"id":141954732,"name":"Queen","nb_fan":397},
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
{"id":183179807,"name":"Queen","nb_fan":53},
{"id":412,"name":"Queen","nb_fan":12744378}
],"total":5}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(412))
})
It("matches the name case-insensitively", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"QUEEN","nb_fan":10},
{"id":2,"name":"queen","nb_fan":20}
],"total":2}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(2))
})
It("returns ErrNotFound when no result matches the name exactly", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
],"total":1}`)),
})
_, err := agent.searchArtist(ctx, "Queen")
Expect(err).To(MatchError(agents.ErrNotFound))
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient

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 {
@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error {
if conf.Server.ListenBrainz.Enabled {
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
}
if conf.Server.Jellyfin.Enabled {
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
}
if conf.Server.Prometheus.Enabled {
p := CreatePrometheus()
// blocking call because takes <100ms but useful if fails
@ -275,16 +278,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

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

@ -31,6 +31,7 @@ import (
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin"
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
@ -116,6 +117,31 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
return router
}
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
players := core.NewPlayers(dataStore)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
return router
}
func CreatePublicRouter() *public.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@ -221,7 +247,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -23,6 +23,7 @@ import (
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin"
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
@ -33,6 +34,7 @@ var allProviders = wire.NewSet(
artwork.Set,
server.New,
subsonic.New,
jellyfin.New,
nativeapi.New,
public.New,
persistence.New,
@ -49,6 +51,7 @@ var allProviders = wire.NewSet(
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.Engine), new(*sonic.Sonic)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
@ -79,6 +82,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
))
}
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
panic(wire.Build(
allProviders,
))
}
func CreatePublicRouter() *public.Router {
panic(wire.Build(
allProviders,

View File

@ -51,6 +51,7 @@ type configOptions struct {
EnableExternalServices bool
EnableM3UExternalAlbumArt bool
EnableInsightsCollector bool
EnableScheduledDBAnalyze bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
@ -116,6 +117,7 @@ type configOptions struct {
LastFM lastfmOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Jellyfin jellyfinOptions `json:",omitzero"`
EnableScrobbleHistory bool
Tags map[string]TagConf `json:",omitempty"`
Agents string
@ -147,7 +149,6 @@ type configOptions struct {
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
DevOptimizeDB bool
DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool
}
@ -218,6 +219,18 @@ type listenBrainzOptions struct {
TrackAlgorithm string
}
type jellyfinOptions struct {
Enabled bool
ServerName string
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
ExposedPublicUsers string
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
MaxConcurrentStreams int
}
type httpHeaderOptions struct {
FrameOptions string
}
@ -800,6 +813,7 @@ func setViperDefaults() {
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)
@ -848,6 +862,8 @@ func setViperDefaults() {
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("jellyfin.enabled", false)
viper.SetDefault("jellyfin.servername", "")
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")
@ -877,6 +893,9 @@ func setViperDefaults() {
viper.SetDefault("devuishowconfig", true)
viper.SetDefault("devneweventstream", true)
viper.SetDefault("devoffsetoptimize", 50000)
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
// scrobbles and the UI. See MaxOpenConns.
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
@ -891,7 +910,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)
}
@ -948,3 +966,14 @@ func getConfigFile(cfgFile string) string {
}
return ""
}
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
// Subsonic, Jellyfin, native API, UI).
//
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
// count is only loosely related to core count, and the floor is what matters on small machines.
func MaxOpenConns() int {
return max(4, runtime.NumCPU())
}

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

View File

@ -20,6 +20,10 @@ const (
LastScanErrorKey = "LastScanError"
LastScanTypeKey = "LastScanType"
LastScanStartTimeKey = "LastScanStartTime"
LastDBAnalyzeAtKey = "LastDBAnalyzeAt"
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
DBAnalyzePendingKey = "DBAnalyzePending"
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
@ -28,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
@ -44,6 +49,11 @@ const (
URLPathSubsonicAPI = "/rest"
URLPathPublic = "/share"
URLPathPublicImages = URLPathPublic + "/img"
URLPathJellyfinAPI = "/jellyfin"
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
JellyfinServerIDKey = "JellyfinServerID"
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
// available at https://unsplash.com/collections/20072696/navidrome

View File

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
)
type ffmpeg struct{}
@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP
return nil, err
}
if err := fileExists(filePath); err != nil {
return nil, err
return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err),
NotFound: errors.Is(err, fs.ErrNotExist), err: err}
}
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
log.Trace(ctx, "Executing ffprobe command", "args", args)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err}
}
return parseProbeOutput(output)
result, err := parseProbeOutput(output)
if err != nil {
return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err}
}
return result, nil
}
// ProbeError reports an ffprobe failure. Reason is a path-free message safe to
// expose to clients; the wrapped cause carries the full detail for logging.
// NotFound marks the media file itself as missing — a launch failure of a
// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer
// it from the error chain.
type ProbeError struct {
Path string
Reason string
NotFound bool
err error
}
func (e *ProbeError) Error() string {
if e.err == nil {
return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason)
}
return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err))
}
// Unwrap exposes the underlying cause so callers can test it with errors.Is
// (e.g. fs.ErrNotExist to detect a missing file).
func (e *ProbeError) Unwrap() error { return e.err }
// SafeReason returns the path-free reason, safe to send to clients.
func (e *ProbeError) SafeReason() string { return e.Reason }
// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved
// or unreadable file reads as "file not found" rather than a raw ffprobe message.
func fileAccessReason(err error) string {
switch {
case errors.Is(err, fs.ErrNotExist):
return "file not found"
case errors.Is(err, fs.ErrPermission):
return "permission denied"
default:
return "file not accessible"
}
}
// probeDetail returns the full diagnostic for logging (may contain paths):
// ffprobe's stderr when present, otherwise the raw error text.
func probeDetail(err error) string {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 {
return strings.TrimSpace(string(exitErr.Stderr))
}
return err.Error()
}
// probeClientReason returns a path-free reason for an ffprobe execution failure:
// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe
// couldn't run at all (its launch error may embed the binary path).
func probeClientReason(err error, path string) string {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || len(exitErr.Stderr) == 0 {
return "could not read file"
}
return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file"))
}
type probeOutput struct {

View File

@ -2,6 +2,7 @@ package ffmpeg
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() {
})
})
Describe("ProbeError", func() {
It("uses the underlying cause in Error() so logs keep the full detail", func() {
e := &ProbeError{Path: "/music/foo.flac",
err: errors.New("/music/foo.flac: Invalid data found when processing input")}
Expect(e.Error()).To(ContainSubstring("/music/foo.flac"))
Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input"))
})
It("returns the path-free reason from SafeReason()", func() {
e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"}
Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input"))
Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac"))
})
It("unwraps to the underlying cause so errors.Is detects a missing file", func() {
e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist}
Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue())
})
})
Describe("probeClientReason", func() {
It("strips the file path from ffprobe stderr", func() {
if runtime.GOOS == "windows" {
Skip("uses /bin/sh")
}
_, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output()
Expect(err).To(HaveOccurred())
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found"))
})
It("returns a generic reason for launch failures, without leaking the binary path", func() {
err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory")
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file"))
})
})
Describe("probeDetail", func() {
It("surfaces ffprobe stderr for logging", func() {
if runtime.GOOS == "windows" {
Skip("uses /bin/sh")
}
_, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output()
Expect(err).To(HaveOccurred())
Expect(probeDetail(err)).To(Equal("boom detail"))
})
})
Describe("fileAccessReason", func() {
It("reports a missing file as 'file not found', not a raw stat message", func() {
_, err := os.Stat("/no/such/dir/really-missing.flac")
Expect(err).To(HaveOccurred())
Expect(fileAccessReason(err)).To(Equal("file not found"))
})
It("falls back to a generic reason for other access errors", func() {
Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible"))
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() {
}
})
It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() {
_, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue())
var pe *ProbeError
Expect(errors.As(err, &pe)).To(BeTrue())
Expect(pe.SafeReason()).To(Equal("file not found"))
Expect(pe.NotFound).To(BeTrue())
})
It("should interrupt transcoding when context is cancelled", func() {
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()

View File

@ -7,6 +7,9 @@ import (
"os"
"path/filepath"
"github.com/dustin/go-humanize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@ -17,6 +20,16 @@ type ImageUploadService interface {
RemoveImage(ctx context.Context, path string) error
}
// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default
// when it's unset/invalid. Shared by every API that accepts image uploads.
func MaxImageUploadSize() int64 {
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
return int64(size)
}
size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize)
return int64(size)
}
type imageUploadService struct{}
func NewImageUploadService() ImageUploadService {

View File

@ -97,3 +97,29 @@ var _ = Describe("ImageUploadService", func() {
})
})
})
var _ = Describe("MaxImageUploadSize", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("returns the configured size when valid", func() {
conf.Server.MaxImageUploadSize = "20MB"
Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000)))
})
It("returns the default size when config is empty", func() {
conf.Server.MaxImageUploadSize = ""
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
})
It("returns the default size when config is invalid", func() {
conf.Server.MaxImageUploadSize = "not-a-size"
Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000)))
})
It("parses raw byte values", func() {
conf.Server.MaxImageUploadSize = "52428800"
Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800)))
})
})

View File

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

@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
@ -187,7 +186,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
newPls.EvaluatedAt = &time.Time{}
newPls.EvaluatedAt = nil // force re-evaluation on next read
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID

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

@ -22,6 +22,7 @@ type Playlists interface {
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
Get(ctx context.Context, id string) (*model.Playlist, error)
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error)
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
// Mutations
@ -98,6 +99,21 @@ func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
}
// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather
// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards
// its error behind a nil (and warns), and this is probed with ids that are usually not playlists.
func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) {
repo := s.ds.Playlist(ctx)
if _, err := repo.Get(id); err != nil {
return nil, err
}
tracks := repo.Tracks(id, true)
if tracks == nil {
return nil, model.ErrNotFound
}
return tracks, nil
}
// --- Mutation operations ---
// Create creates a new playlist (when name is provided) or replaces tracks on an existing

View File

@ -73,6 +73,28 @@ var _ = Describe("Playlists", func() {
})
})
Describe("Tracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
})
It("returns the playlist's track repository", func() {
Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks))
})
It("returns ErrNotFound for an unknown or invisible playlist", func() {
_, err := ps.Tracks(ctx, "nonexistent")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Create", func() {
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{

View File

@ -135,6 +135,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod
}
if rulesChanged {
current.Rules = entity.Rules
current.EvaluatedAt = nil // force re-evaluation on next read
}
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync

View File

@ -314,6 +314,38 @@ var _ = Describe("REST Adapter", func() {
Expect(mockPlsRepo.Last.Public).To(BeTrue())
})
It("resets EvaluatedAt when rules change", func() {
evaluatedAt := time.Now().Add(-1 * time.Hour)
mockPlsRepo.Data["smart-reset"] = &model.Playlist{
ID: "smart-reset",
Name: "Smart",
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
EvaluatedAt: &evaluatedAt,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}}
err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil())
})
It("keeps EvaluatedAt when rules are not changed", func() {
evaluatedAt := time.Now().Add(-1 * time.Hour)
mockPlsRepo.Data["smart-keep"] = &model.Playlist{
ID: "smart-keep",
Name: "Smart",
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
EvaluatedAt: &evaluatedAt,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil())
Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
})
It("updates name and rules together (smart-playlist Edit form)", func() {
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
ID: "smart-edit",

View File

@ -7,8 +7,33 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
const (
minRetryDelay = 5 * time.Second
maxRetryDelay = 4 * time.Minute
// maxRetryShift caps the exponent so the shift never overflows int64.
// minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling.
maxRetryShift = 6
)
// backoffDelay returns the delay for a zero-based retry index (0 = first retry):
// minRetryDelay doubled per prior failure, clamped to maxRetryDelay.
func backoffDelay(failures int) time.Duration {
if failures < 0 {
failures = 0
}
if failures >= maxRetryShift {
return maxRetryDelay
}
d := minRetryDelay << failures
if d > maxRetryDelay {
return maxRetryDelay
}
return d
}
// Loader is a function that loads a scrobbler by name.
// It returns the scrobbler and true if found, or nil and false if not available.
// This allows the buffered scrobbler to always get the current plugin instance.
@ -97,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() {
}
func (b *bufferedScrobbler) run(ctx context.Context) {
timer := time.NewTimer(time.Hour)
timer.Stop()
defer timer.Stop()
failures := 0
for {
if !b.processQueue(ctx) {
time.AfterFunc(5*time.Second, func() {
b.sendWakeSignal()
})
if b.processQueue(ctx) {
failures = 0
timer.Stop()
} else {
timer.Reset(backoffDelay(failures))
if failures < maxRetryShift {
failures++
}
}
select {
case <-b.wakeSignal:
continue
case <-timer.C:
case <-ctx.Done():
return
}
@ -129,6 +162,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

@ -2,6 +2,9 @@ package scrobbler
import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/navidrome/navidrome/model"
@ -20,8 +23,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 +68,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{})
@ -87,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() {
}
})
})
var _ = Describe("backoffDelay", func() {
DescribeTable("computes the exponential backoff curve clamped to the ceiling",
func(failures int, expected time.Duration) {
Expect(backoffDelay(failures)).To(Equal(expected))
},
Entry("first failure", 0, 5*time.Second),
Entry("second failure", 1, 10*time.Second),
Entry("third failure", 2, 20*time.Second),
Entry("fourth failure", 3, 40*time.Second),
Entry("fifth failure", 4, 80*time.Second),
Entry("sixth failure", 5, 160*time.Second),
Entry("reaches the ceiling", 6, 4*time.Minute),
Entry("stays clamped past the ceiling", 7, 4*time.Minute),
Entry("stays clamped for large values", 1000, 4*time.Minute),
Entry("negative is treated as zero", -1, 5*time.Second),
)
})
// Drives the real run loop and asserts the exact retry schedule + recovery. Plain
// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give.
func TestBufferedScrobblerBackoffSchedule(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
buffer := tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
flaky := &recoveringScrobbler{}
flaky.fail(ErrRetryLater)
bs := newBufferedScrobbler(ds, flaky, "flaky")
defer func() { bs.Stop(); synctest.Wait() }()
// Let the loop settle on the empty buffer, then enqueue a scrobble.
synctest.Wait()
track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"}
g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed())
// First attempt fires immediately on the enqueue wake and is left buffered.
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(int32(1)))
g.Expect(buffer.Length()).To(Equal(int64(1)))
// Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s.
for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} {
want := int32(i + 2)
time.Sleep(gap - time.Nanosecond)
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap)
time.Sleep(time.Nanosecond)
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap)
}
// Once the service recovers, waking the loop drains the buffered entry.
flaky.succeed()
bs.sendWakeSignal()
synctest.Wait()
g.Expect(buffer.Length()).To(Equal(int64(0)))
})
}
// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while
// the buffered scrobbler's goroutine is draining, to exercise retry then recovery.
type recoveringScrobbler struct {
err atomic.Pointer[error]
count atomic.Int32
}
func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) }
func (f *recoveringScrobbler) succeed() { f.err.Store(nil) }
func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true }
func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error {
return nil
}
func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error {
f.count.Add(1)
if e := f.err.Load(); e != nil {
return *e
}
return nil
}
func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil }

View File

@ -89,6 +89,7 @@ type playTracker struct {
ds model.DataStore
broker events.Broker
playMap cache.SimpleCache[string, PlaybackSession]
sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports
builtinScrobblers map[string]Scrobbler
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
@ -249,6 +250,12 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
return combined
}
// hasPlayingSession reports whether clientId's current session is already playing mediaId.
func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool {
cur, err := p.playMap.Get(clientId)
return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying
}
func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
if rate <= 0 {
rate = 1.0
@ -268,6 +275,12 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
switch params.State {
case StateStarting:
// Clients may send starting/playing unordered; a late "starting" must not downgrade
// a playing session, or position estimation freezes until the next report.
if p.hasPlayingSession(clientId, params.MediaId) {
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
return nil
}
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
@ -284,7 +297,15 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
p.sessionsMu.Lock()
// re-check: a concurrent "playing" report may have created the session during the load above
if p.hasPlayingSession(clientId, params.MediaId) {
p.sessionsMu.Unlock()
log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId)
return nil
}
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
p.sessionsMu.Unlock()
if err != nil {
log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
@ -315,7 +336,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
}
log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl)
p.sessionsMu.Lock()
err := p.playMap.AddWithTTL(clientId, info, ttl)
p.sessionsMu.Unlock()
if err != nil {
log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
@ -339,6 +362,17 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
p.dispatchScrobble(ctx, mf, now)
}
}
p.sessionsMu.Lock()
info, getErr := p.playMap.Get(clientId)
// A late stop for a previous track must not end the current session nor reach
// playback reporters, or presence-style plugins would clear the active track.
if getErr == nil && info.MediaFile.ID != params.MediaId {
p.sessionsMu.Unlock()
log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID)
return nil
}
p.playMap.Remove(clientId)
p.sessionsMu.Unlock()
stoppedInfo := PlaybackSession{
UserId: user.ID,
Username: user.UserName,
@ -349,7 +383,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
if info, getErr := p.playMap.Get(clientId); getErr == nil {
if getErr == nil {
stoppedInfo.MediaFile = info.MediaFile
stoppedInfo.Start = info.Start
} else {
@ -364,7 +398,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
stoppedInfo.MediaFile = *mf
}
p.enqueuePlaybackReport(ctx, stoppedInfo)
p.playMap.Remove(clientId)
}
if conf.Server.EnableNowPlaying {
@ -491,3 +524,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

@ -3,6 +3,7 @@ package scrobbler
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
@ -45,6 +46,17 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
return s, ok
}
// slowMediaFileRepo widens the window between a report's session check and its
// write, making check-then-write races reproducible.
type slowMediaFileRepo struct {
model.MediaFileRepository
}
func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
time.Sleep(5 * time.Millisecond)
return s.MediaFileRepository.GetWithParticipants(id)
}
var _ = Describe("PlayTracker", func() {
var ctx context.Context
var ds model.DataStore
@ -104,6 +116,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 +302,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() {
@ -369,18 +388,23 @@ var _ = Describe("PlayTracker", func() {
Expect(playing).To(BeEmpty())
})
It("starting replaces existing entry for same player", func() {
It("starting replaces existing entry when switching tracks on same player", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
MediaId: "456", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].MediaFile.ID).To(Equal("456"))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
@ -689,6 +713,119 @@ var _ = Describe("PlayTracker", func() {
})
})
Describe("resilience (out-of-order reports)", func() {
BeforeEach(func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
})
It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 1000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000)))
})
It("keeps the current session when a stopped report arrives for a different track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].MediaFile.ID).To(Equal("456"))
Expect(playing[0].State).To(Equal("playing"))
})
It("still auto-scrobbles the stopped track when the current session is for a different track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
})
It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
fake.nowPlayingCalled.Store(false)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("never lets a concurrent starting report downgrade the playing session", func() {
ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)}
for i := range 20 {
raceClientId := fmt.Sprintf("race-client-%d", i)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
defer GinkgoRecover()
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId,
})
}()
go func() {
defer wg.Done()
defer GinkgoRecover()
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId,
})
}()
wg.Wait()
info, err := tracker.playMap.Get(raceClientId)
Expect(err).ToNot(HaveOccurred())
Expect(info.State).To(Equal("playing"), "iteration %d", i)
}
})
It("does NOT forward a stopped report for a different track to playback reporters", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue())
fake.PlaybackReportCalled.Store(false)
fake.LastPlaybackReport.Store(nil)
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse())
})
})
Describe("external scrobbler dispatch", func() {
It("dispatches NowPlaying on starting", func() {
fake.nowPlayingCalled.Store(false)
@ -1091,6 +1228,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()
}
@ -1122,6 +1266,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 {

View File

@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher
}
}
// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it.
type Engine interface {
HasProvider() bool
GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error)
FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error)
}
var _ Engine = (*Sonic)(nil)
func (s *Sonic) HasProvider() bool {
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
}

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

@ -199,6 +199,78 @@ 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

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

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

@ -656,6 +656,44 @@ 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: new(16)})
ci := &ClientInfo{
@ -695,9 +733,9 @@ 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() {

View File

@ -5,7 +5,7 @@ import (
"database/sql"
"embed"
"fmt"
"runtime"
"time"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
@ -43,16 +43,10 @@ func Db() *sql.DB {
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
db, err := sql.Open(Driver, Path)
db.SetMaxOpenConns(max(4, runtime.NumCPU()))
db.SetMaxOpenConns(conf.MaxOpenConns())
if err != nil {
log.Fatal("Error opening database", err)
}
if conf.Server.DevOptimizeDB {
_, err = db.Exec("PRAGMA optimize=0x10002")
if err != nil {
log.Error("Error applying PRAGMA optimize", err)
}
}
return db
})
}
@ -61,9 +55,6 @@ func Close(ctx context.Context) {
// Ignore cancellations when closing the DB
ctx = context.WithoutCancel(ctx)
// Run optimize before closing
Optimize(ctx)
log.Info(ctx, "Closing Database")
err := Db().Close()
if err != nil {
@ -102,11 +93,11 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err)
}
if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize")
if hasSchemaChanges {
log.Debug(ctx, "Running ANALYZE after schema changes")
err = optimizeAt(ctx, db, time.Now())
if err != nil {
log.Error(ctx, "Error applying PRAGMA optimize", err)
log.Error(ctx, "Error running ANALYZE", err)
}
}
@ -115,37 +106,6 @@ func Init(ctx context.Context) func() {
}
}
// Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) {
if !conf.Server.DevOptimizeDB {
return
}
numConns := Db().Stats().OpenConnections
if numConns == 0 {
log.Debug(ctx, "No open connections to optimize")
return
}
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
var conns []*sql.Conn
for range numConns {
conn, err := Db().Conn(ctx)
conns = append(conns, conn)
if err != nil {
log.Error(ctx, "Error getting connection from pool", err)
continue
}
_, err = conn.ExecContext(ctx, "PRAGMA optimize;")
if err != nil {
log.Error(ctx, "Error running PRAGMA optimize", err)
}
}
// Return all connections to the Connection Pool
for _, conn := range conns {
conn.Close()
}
}
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }

View File

@ -2,6 +2,9 @@ package db
// Definitions for testing private methods
var (
IsSchemaEmpty = isSchemaEmpty
BackupPath = backupPath
IsSchemaEmpty = isSchemaEmpty
BackupPath = backupPath
OptimizeDBAt = optimizeAt
OptimizeDBIfNeeded = optimizeIfNeeded
RecordAnalyzeFailure = recordAnalyzeFailure
)

View File

@ -0,0 +1,39 @@
-- +goose Up
CREATE TABLE scrobbles_tmp(
id INTEGER PRIMARY KEY,
media_file_id VARCHAR(255) NOT NULL
REFERENCES media_file(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
user_id VARCHAR(255) NOT NULL
REFERENCES user(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
submission_time INTEGER NOT NULL
);
INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles;
DROP INDEX scrobbles_date;
DROP TABLE scrobbles;
ALTER TABLE scrobbles_tmp RENAME TO scrobbles;
CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time);
-- +goose Down
CREATE TABLE scrobbles_tmp(
media_file_id VARCHAR(255) NOT NULL
REFERENCES media_file(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
user_id VARCHAR(255) NOT NULL
REFERENCES user(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
submission_time INTEGER NOT NULL
);
INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles;
DROP INDEX scrobbles_user_time;
DROP TABLE scrobbles;
ALTER TABLE scrobbles_tmp RENAME TO scrobbles;
CREATE INDEX scrobbles_date ON scrobbles(submission_time);

View File

@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE playlist DROP COLUMN average_rating;

View File

@ -0,0 +1,22 @@
-- +goose Up
-- +goose StatementBegin
-- Covering index for the title-sorted, library-scoped song listing:
-- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m
-- (Jellyfin clients page through the whole library this way; non-admin native and
-- Subsonic song lists produce the same shape.)
--
-- Without it, SQLite walks media_file_order_title and must fetch the table row for
-- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit
-- random row reads (seconds on cold spinning disks). With the filter columns in the
-- index the skip is index-only. `id` is included because the annotation/bookmark
-- LEFT JOINs run per candidate row and need the join key; without it each skipped
-- entry still triggers a row fetch.
create index if not exists media_file_missing_library_order_title
on media_file(missing, library_id, order_title, id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
drop index if exists media_file_missing_library_order_title;
-- +goose StatementEnd

View File

@ -0,0 +1,41 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain)
}
func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error {
// Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows
// into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album.
_, err := tx.ExecContext(ctx, `
ALTER TABLE album ADD COLUMN rg_album_gain real;
ALTER TABLE album ADD COLUMN rg_album_peak real;
CREATE TEMP TABLE _rg_backfill AS
SELECT album_id, rg_album_gain, rg_album_peak FROM media_file
WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL;
CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id);
UPDATE album SET
rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL
GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1),
rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL
GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1)
WHERE album.id IN (SELECT album_id FROM _rg_backfill);
DROP TABLE _rg_backfill;
`)
return err
}
func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -7,7 +7,6 @@ import (
"strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
)
@ -21,13 +20,6 @@ func notice(ctx context.Context, tx *sql.Tx, msg string) {
// Call this in migrations that requires a full rescan
func forceFullRescan(ctx context.Context, tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
if conf.Server.DevOptimizeDB {
_, err := tx.ExecContext(ctx, `ANALYZE;`)
if err != nil {
return err
}
}
_, err := tx.ExecContext(ctx, fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey))

224
db/optimize.go Normal file
View File

@ -0,0 +1,224 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
)
var analyzeMux sync.Mutex
// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided
// because its limited analysis misestimates Navidrome's low-cardinality indexes.
func Optimize(ctx context.Context) error {
analyzeMux.Lock()
defer analyzeMux.Unlock()
start := time.Now()
if err := optimizeAt(ctx, Db(), start); err != nil {
return err
}
log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start))
return nil
}
// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation
// marked them for refresh.
func OptimizeIfNeeded(ctx context.Context) (bool, error) {
analyzeMux.Lock()
defer analyzeMux.Unlock()
start := time.Now()
ran, err := optimizeIfNeeded(ctx, Db(), start)
if err != nil || !ran {
return ran, err
}
log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start))
return true, nil
}
func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) {
due, err := optimizeDue(ctx, db, now)
if err != nil || !due {
return false, err
}
return true, optimizeAt(ctx, db, now)
}
func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) {
backingOff, err := analyzeRetryBackoffActive(ctx, db, now)
if err != nil || backingOff {
return false, err
}
pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey)
if err != nil {
return false, err
}
if found && pending == "1" {
return true, nil
}
value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey)
if err != nil {
return false, err
}
if !found {
return true, nil
}
lastAnalyze, valid := parseAnalyzeTime(value)
if !valid || lastAnalyze.After(now) {
return true, nil
}
return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil
}
func parseAnalyzeTime(value string) (time.Time, bool) {
parsed, err := time.Parse(time.RFC3339Nano, value)
return parsed, err == nil
}
func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) {
value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey)
if err != nil || !found {
return false, err
}
failures, _ := strconv.Atoi(value)
if failures < 1 {
return false, nil
}
value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey)
if err != nil || !found {
return false, err
}
lastAttempt, valid := parseAnalyzeTime(value)
if !valid || lastAttempt.After(now) {
return false, nil
}
return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil
}
func analyzeRetryDelay(failures int) time.Duration {
switch failures {
case 1:
return 30 * time.Minute
case 2:
return time.Hour
case 3:
return 2 * time.Hour
default:
return 24 * time.Hour
}
}
// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check.
func MarkOptimizePending(ctx context.Context) error {
analyzeMux.Lock()
defer analyzeMux.Unlock()
return markOptimizePending(ctx, Db())
}
func markOptimizePending(ctx context.Context, db *sql.DB) error {
return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1")
}
func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error {
if err := markOptimizePending(ctx, db); err != nil {
return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err))
}
log.Debug(ctx, "Refreshing query planner statistics")
_, err := db.ExecContext(ctx, "ANALYZE")
if err != nil {
return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err))
}
if err = recordAnalyzeSuccess(ctx, db, now); err != nil {
return recordAnalyzeError(ctx, db, now, err)
}
return nil
}
func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("recording ANALYZE time: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("recording ANALYZE time: %w", err)
}
if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil {
return fmt.Errorf("clearing pending ANALYZE: %w", err)
}
if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil {
return fmt.Errorf("clearing ANALYZE failure count: %w", err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("recording ANALYZE state: %w", err)
}
return nil
}
func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error {
if err := recordAnalyzeFailure(ctx, db, now); err != nil {
return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err))
}
return analyzeErr
}
func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey)
if err != nil {
return err
}
failures := 0
if found {
failures, _ = strconv.Atoi(value)
failures = max(failures, 0)
}
if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil {
return err
}
if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil {
return err
}
if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil {
return err
}
return tx.Commit()
}
type sqlExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
type sqlQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func putProperty(ctx context.Context, db sqlExecer, key, value string) error {
_, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?)
on conflict(id) do update set value=excluded.value`, key, value)
return err
}
func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) {
var value string
err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
return value, err == nil, err
}

162
db/optimize_test.go Normal file
View File

@ -0,0 +1,162 @@
package db_test
import (
"context"
"database/sql"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Optimize", func() {
var (
ctx context.Context
database *sql.DB
now time.Time
)
BeforeEach(func() {
ctx = context.Background()
now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC)
var err error
database, err = sql.Open(db.Dialect, "file::memory:")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(database.Close)
_, err = database.Exec(`create table property(
id varchar(255) primary key,
value varchar(255) not null default ''
)`)
Expect(err).ToNot(HaveOccurred())
_, err = database.Exec("create table analyze_probe(id integer primary key, flag int)")
Expect(err).ToNot(HaveOccurred())
_, err = database.Exec(`insert into analyze_probe(flag)
with recursive s(x) as (select 1 union all select x+1 from s where x < 3000)
select 0 from s`)
Expect(err).ToNot(HaveOccurred())
_, err = database.Exec("create index probe_flag on analyze_probe(flag)")
Expect(err).ToNot(HaveOccurred())
_, err = database.Exec("analyze")
Expect(err).ToNot(HaveOccurred())
})
putProperty := func(key, value string) {
_, err := database.Exec(`insert into property(id, value) values(?, ?)
on conflict(id) do update set value=excluded.value`, key, value)
Expect(err).ToNot(HaveOccurred())
}
getProperty := func(key string) string {
var value string
Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed())
return value
}
poisonStats := func() {
_, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'")
Expect(err).ToNot(HaveOccurred())
}
It("replaces poisoned planner statistics with full-quality ones", func() {
poisonStats()
putProperty(consts.DBAnalyzePendingKey, "1")
Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed())
var stat string
err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)
Expect(err).ToNot(HaveOccurred())
// A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count.
Expect(stat).To(Equal("3000 3000"))
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
})
It("runs when no previous analysis was recorded", func() {
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
Expect(err).ToNot(HaveOccurred())
Expect(ran).To(BeTrue())
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
})
It("skips a recent analysis when no refresh is pending", func() {
lastAnalyze := now.Add(-23 * time.Hour)
putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano))
putProperty(consts.DBAnalyzePendingKey, "0")
poisonStats()
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
Expect(err).ToNot(HaveOccurred())
Expect(ran).To(BeFalse())
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano)))
var stat string
Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed())
Expect(stat).To(Equal("3000 50"))
})
It("runs when the previous analysis is stale", func() {
putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano))
putProperty(consts.DBAnalyzePendingKey, "0")
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
Expect(err).ToNot(HaveOccurred())
Expect(ran).To(BeTrue())
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
})
It("runs when a refresh is pending even if the previous analysis is recent", func() {
putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano))
putProperty(consts.DBAnalyzePendingKey, "1")
ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour))
Expect(err).ToNot(HaveOccurred())
Expect(ran).To(BeTrue())
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
})
DescribeTable("backs off after consecutive analysis failures",
func(failures string, retryDelay time.Duration) {
putProperty(consts.DBAnalyzePendingKey, "1")
putProperty(consts.DBAnalyzeFailureCountKey, failures)
putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano))
ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond))
Expect(err).ToNot(HaveOccurred())
Expect(ran).To(BeFalse())
ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay))
Expect(err).ToNot(HaveOccurred())
Expect(ran).To(BeTrue())
Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0"))
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
},
Entry("for 30 minutes after the first failure", "1", 30*time.Minute),
Entry("for one hour after the second failure", "2", time.Hour),
Entry("for two hours after the third failure", "3", 2*time.Hour),
Entry("for 24 hours after the fourth failure", "4", 24*time.Hour),
)
It("records consecutive analysis failures", func() {
putProperty(consts.DBAnalyzeFailureCountKey, "2")
Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed())
Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3"))
Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1"))
})
It("does not record success when analysis fails", func() {
lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano)
putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze)
canceledCtx, cancel := context.WithCancel(ctx)
cancel()
Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled")))
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze))
})
})

42
go.mod
View File

@ -3,7 +3,7 @@ module github.com/navidrome/navidrome
go 1.26
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3
require (
github.com/Masterminds/squirrel v1.5.4
@ -19,10 +19,10 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
github.com/gen2brain/webp v0.6.3
github.com/go-chi/chi/v5 v5.3.0
github.com/gen2brain/webp v0.6.4
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/httprate v0.16.0
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
@ -33,18 +33,18 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/kardianos/service v1.2.4
github.com/kardianos/service v1.3.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/mattn/go-sqlite3 v1.14.47
github.com/mattn/go-sqlite3 v1.14.48
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.32.0
github.com/onsi/gomega v1.42.1
github.com/pelletier/go-toml/v2 v2.4.2
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pmezard/go-difflib v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.1
github.com/pressly/goose/v3 v3.27.2
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
@ -59,12 +59,12 @@ require (
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.43.0
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
golang.org/x/image v0.44.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@ -75,7 +75,7 @@ require (
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/reflex v0.3.1 // indirect
github.com/cespare/reflex v0.3.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@ -89,14 +89,14 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@ -133,10 +133,10 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect

102
go.sum
View File

@ -16,13 +16,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -32,8 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs=
github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU=
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@ -62,30 +61,29 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gen2brain/webp v0.6.3 h1:DbXXCkiHN6zq2qIuTsPSZQhVi2VcQ0UPzKbKqKENfsQ=
github.com/gen2brain/webp v0.6.3/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8=
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -134,20 +132,17 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@ -174,8 +169,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@ -196,8 +191,8 @@ github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -206,8 +201,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8=
github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -309,39 +304,38 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc=
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@ -356,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=

View File

@ -45,8 +45,10 @@ var redacted = &Hook{
"([^\\w]p=)[^&]+",
"([^\\w]jwt=)[^&]+",
// External services query params
"([^\\w]api_key=)[\\w]+",
// External services query params. Values can be JWTs (dots, dashes), so match everything up
// to the next query separator or whitespace, not just word chars. A [\w]+ class would stop
// at a JWT's first '.' and leak its payload and signature.
"([^\\w]api_key=)[^&\\s]+",
},
}

View File

@ -259,5 +259,10 @@ var _ = Describe("Logger", func() {
msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title"
Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title"))
})
It("redacts a whole JWT in api_key, not just up to its first dot", func() {
msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1"
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1"))
})
})
})

View File

@ -1,7 +1,6 @@
package model
import (
"fmt"
"iter"
"math"
"sync"
@ -49,6 +48,8 @@ type Album struct {
MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"`
RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
// External metadata fields
Description string `structs:"description" json:"description,omitempty" hash:"ignore"`
@ -75,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID {
func (a Album) FullName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0])
}
return a.Name
}
@ -141,6 +142,8 @@ type AlbumRepository interface {
UpdateExternalInfo(*Album) error
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetCursor(...QueryOptions) (AlbumCursor, error)
GetYears(libraryIDs ...int) ([]int, error)
// The following methods are used exclusively by the scanner:
Touch(ids ...string) error

View File

@ -24,6 +24,8 @@ var _ = Describe("Album", func() {
Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"),
Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"),
)
})

View File

@ -1,6 +1,7 @@
package model
import (
"iter"
"maps"
"slices"
"time"
@ -79,6 +80,8 @@ type ArtistIndex struct {
}
type ArtistIndexes []ArtistIndex
type ArtistCursor iter.Seq2[Artist, error]
type ArtistRepository interface {
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
@ -86,6 +89,7 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
GetCursor(options ...QueryOptions) (ArtistCursor, error)
GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner:

View File

@ -4,9 +4,12 @@ package criteria
import (
"encoding/json"
"errors"
"fmt"
"slices"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
)
type Expression interface {
@ -20,6 +23,7 @@ type Criteria struct {
Limit int
LimitPercent int
Offset int
RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay
}
// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is
@ -98,6 +102,7 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
Limit int `json:"limit,omitempty"`
LimitPercent int `json:"limitPercent,omitempty"`
Offset int `json:"offset,omitempty"`
RefreshDelay string `json:"refreshDelay,omitempty"`
}{
Sort: c.Sort,
Order: c.Order,
@ -105,6 +110,9 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
LimitPercent: c.LimitPercent,
Offset: c.Offset,
}
if c.RefreshDelay > 0 {
aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay)
}
switch rules := c.Expression.(type) {
case Any:
aux.Any = rules
@ -118,21 +126,27 @@ func (c Criteria) MarshalJSON() ([]byte, error) {
func (c *Criteria) UnmarshalJSON(data []byte) error {
var aux struct {
All unmarshalConjunctionType `json:"all"`
Any unmarshalConjunctionType `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
LimitPercent int `json:"limitPercent"`
Offset int `json:"offset"`
All optionalConjunction `json:"all"`
Any optionalConjunction `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
LimitPercent int `json:"limitPercent"`
Offset int `json:"offset"`
RefreshDelay string `json:"refreshDelay"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if len(aux.Any) > 0 {
c.Expression = Any(aux.Any)
} else if len(aux.All) > 0 {
c.Expression = All(aux.All)
// A Criteria has a single top-level group. Reject files that provide both keys
// (even when one is [] or null) rather than silently dropping one of them.
if aux.All.present && aux.Any.present {
return errors.New("invalid criteria json: 'all' and 'any' cannot both be used at the top level; nest one inside the other instead")
}
if len(aux.Any.rules) > 0 {
c.Expression = Any(aux.Any.rules)
} else if len(aux.All.rules) > 0 {
c.Expression = All(aux.All.rules)
} else {
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
}
@ -141,6 +155,14 @@ func (c *Criteria) UnmarshalJSON(data []byte) error {
c.Limit = aux.Limit
c.Offset = aux.Offset
if aux.RefreshDelay != "" {
d, err := utils.ParseDuration(aux.RefreshDelay)
if err != nil {
return fmt.Errorf("invalid refreshDelay: %w", err)
}
c.RefreshDelay = d
}
// Clamp LimitPercent to [0, 100]
if aux.LimitPercent < 0 {
log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent)

View File

@ -3,6 +3,7 @@ package criteria
import (
"bytes"
"encoding/json"
"time"
"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
@ -80,6 +81,28 @@ var _ = Describe("Criteria", func() {
})
})
Context("with both top-level 'all' and 'any'", func() {
It("returns an error instead of silently dropping one of the groups", func() {
jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}`
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
})
DescribeTable("rejects both keys even when one group is present but empty",
func(jsonStr string) {
var c Criteria
err := json.Unmarshal([]byte(jsonStr), &c)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any")))
},
Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`),
Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`),
Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`),
)
})
Describe("LimitPercent", func() {
Describe("JSON round-trip", func() {
It("marshals and unmarshals limitPercent", func() {
@ -233,6 +256,71 @@ var _ = Describe("Criteria", func() {
})
})
Describe("refreshDelay", func() {
newCriteria := func(extra string) []byte {
return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`)
}
It("unmarshals a valid refreshDelay", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour))
})
It("supports week units", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour))
})
It("leaves RefreshDelay zero when absent", func() {
var c Criteria
gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed())
gomega.Expect(c.RefreshDelay).To(gomega.BeZero())
})
It("rejects an invalid refreshDelay", func() {
var c Criteria
err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
})
It("rejects a negative refreshDelay", func() {
var c Criteria
err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))
})
It("marshals RefreshDelay back as a duration string", func() {
c := Criteria{
Expression: All{Is{"loved": true}},
RefreshDelay: 24 * time.Hour,
}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`))
})
It("omits refreshDelay from JSON when zero", func() {
c := Criteria{Expression: All{Is{"loved": true}}}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay"))
})
It("round-trips through marshal and unmarshal", func() {
c := Criteria{
Expression: All{Is{"loved": true}},
RefreshDelay: 36 * time.Hour,
}
j, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
var c2 Criteria
gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed())
gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour))
})
})
Context("with child playlists", func() {
var (
topLevelInPlaylistID string

View File

@ -33,6 +33,20 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
return nil
}
// optionalConjunction is a top-level "all"/"any" value that remembers whether its
// key was present at all, so a Criteria providing both can be rejected. encoding/json
// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears
// — including as [] or null — while an absent key leaves it false.
type optionalConjunction struct {
present bool
rules unmarshalConjunctionType
}
func (o *optionalConjunction) UnmarshalJSON(data []byte) error {
o.present = true
return json.Unmarshal(data, &o.rules)
}
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
m := make(map[string]any)
err := json.Unmarshal(rawValue, &m)

View File

@ -2,29 +2,26 @@ package model
import (
"context"
"errors"
)
// TODO: Should the type be encoded in the ID?
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
ar, err := ds.Artist(ctx).Get(id)
if err == nil {
return ar, nil
getters := []func() (any, error){
func() (any, error) { return ds.Artist(ctx).Get(id) },
func() (any, error) { return ds.Album(ctx).Get(id) },
func() (any, error) { return ds.Playlist(ctx).Get(id) },
func() (any, error) { return ds.MediaFile(ctx).Get(id) },
func() (any, error) { return ds.Radio(ctx).Get(id) },
}
al, err := ds.Album(ctx).Get(id)
if err == nil {
return al, nil
for _, get := range getters {
entity, err := get()
if err == nil {
return entity, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
}
pls, err := ds.Playlist(ctx).Get(id)
if err == nil {
return pls, nil
}
mf, err := ds.MediaFile(ctx).Get(id)
if err == nil {
return mf, nil
}
r, err := ds.Radio(ctx).Get(id)
if err == nil {
return r, nil
}
return nil, err
return nil, ErrNotFound
}

40
model/get_entity_test.go Normal file
View File

@ -0,0 +1,40 @@
package model_test
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GetEntityByID", func() {
var ds *tests.MockDataStore
var ctx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = GinkgoT().Context()
})
It("returns the entity matching the id", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
entity, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).ToNot(HaveOccurred())
Expect(entity).To(BeAssignableToTypeOf(&model.Album{}))
Expect(entity.(*model.Album).ID).To(Equal("a1"))
})
It("returns ErrNotFound when no entity matches", func() {
_, err := model.GetEntityByID(ctx, ds, "missing")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("propagates unexpected repository errors instead of reporting not-found", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
_, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(model.ErrNotFound))
})
})

View File

@ -100,18 +100,28 @@ type MediaFile struct {
func (mf MediaFile) FullTitle() string {
if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0])
}
return mf.Title
}
func (mf MediaFile) FullAlbumName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0])
}
return mf.Album
}
var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'}
func appendSuffix(base, suffix string) string {
suffix = strings.TrimSpace(suffix)
if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] {
return base + " " + suffix
}
return base + " (" + suffix + ")"
}
func (mf MediaFile) ContentType() string {
return mime.TypeByExtension("." + mf.Suffix)
}
@ -147,6 +157,12 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) {
return lyrics, nil
}
// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan;
// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless.
func (mf MediaFile) HasEmbeddedLyrics() bool {
return mf.Lyrics != "" && mf.Lyrics != "[]"
}
// String is mainly used for debugging
func (mf MediaFile) String() string {
return mf.Path
@ -308,6 +324,8 @@ func (mfs MediaFiles) ToAlbum() Album {
originalYears := make([]int, 0, len(mfs))
originalDates := make([]string, 0, len(mfs))
releaseDates := make([]string, 0, len(mfs))
rgAlbumGains := make([]*float64, 0, len(mfs))
rgAlbumPeaks := make([]*float64, 0, len(mfs))
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
a.Missing = true
@ -338,6 +356,8 @@ func (mfs MediaFiles) ToAlbum() Album {
originalYears = append(originalYears, m.OriginalYear)
originalDates = append(originalDates, m.OriginalDate)
releaseDates = append(releaseDates, m.ReleaseDate)
rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain)
rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak)
comments = append(comments, m.Comment)
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
@ -372,6 +392,8 @@ func (mfs MediaFiles) ToAlbum() Album {
a.Comment, _ = allOrNothing(comments)
a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds)
a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds)
a.RGAlbumGain = mostFrequentPtr(rgAlbumGains)
a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks)
fixAlbumArtist(&a)
return a
@ -401,6 +423,32 @@ func minMax(items []int) (int, int) {
return mn, mx
}
// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if
// none. It counts by dereferenced value so a genuine 0.0 is a real candidate
// (slice.MostFrequent skips the zero value and compares pointers by identity).
func mostFrequentPtr(items []*float64) *float64 {
var counts map[float64]int
var best float64
var bestCount int
for _, it := range items {
if it == nil {
continue
}
if counts == nil {
counts = map[float64]int{}
}
counts[*it]++
if counts[*it] > bestCount {
bestCount = counts[*it]
best = *it
}
}
if bestCount == 0 {
return nil
}
return &best
}
func newer(t1, t2 time.Time) time.Time {
if t1.After(t2) {
return t1

View File

@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() {
})
})
})
Context("ReplayGain", func() {
It("picks the most frequent non-nil album gain and peak", func() {
mfs := MediaFiles{
{Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
{Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
{Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)},
}
album := mfs.ToAlbum()
Expect(album.RGAlbumGain).ToNot(BeNil())
Expect(*album.RGAlbumGain).To(Equal(-8.0))
Expect(album.RGAlbumPeak).ToNot(BeNil())
Expect(*album.RGAlbumPeak).To(Equal(0.9))
})
It("keeps a genuine 0.0 gain instead of dropping it", func() {
mfs := MediaFiles{
{Path: "a", RGAlbumGain: new(0.0)},
{Path: "b", RGAlbumGain: new(0.0)},
}
album := mfs.ToAlbum()
Expect(album.RGAlbumGain).ToNot(BeNil())
Expect(*album.RGAlbumGain).To(Equal(0.0))
})
It("leaves gain and peak nil when no track has a value", func() {
mfs := MediaFiles{{Path: "a"}, {Path: "b"}}
album := mfs.ToAlbum()
Expect(album.RGAlbumGain).To(BeNil())
Expect(album.RGAlbumPeak).To(BeNil())
})
})
Context("Participants", func() {
var album Album
BeforeEach(func() {
@ -504,6 +533,13 @@ var _ = Describe("MediaFile", func() {
Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
Entry("returns just title when tag is absent", true, Tags{}, "Song"),
Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"),
Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"),
Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"),
Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{"<Live>"}}, "Song <Live>"),
Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"),
Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"),
Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"),
)
DescribeTable("FullAlbumName",
func(enabled bool, tags Tags, expected string) {
@ -515,6 +551,8 @@ var _ = Describe("MediaFile", func() {
Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"),
Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"),
)
Describe("CoverArtId", func() {
It("returns its own id if it HasCoverArt", func() {
@ -604,6 +642,15 @@ var _ = Describe("MediaFile", func() {
})
var _ = DescribeTable("MediaFile.HasEmbeddedLyrics",
func(lyrics string, expected bool) {
Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected))
},
Entry("empty string (never-scanned zero value)", "", false),
Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false),
Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true),
)
var _ = Describe("MediaFile.Works", func() {
It("returns nil when there are no work tags", func() {
mf := MediaFile{}

View File

@ -1,17 +1,21 @@
package model
import (
"iter"
"maps"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria"
)
type Playlist struct {
Annotations `structs:"-"`
ID string `structs:"id" json:"id"`
Name string `structs:"name" json:"name"`
Comment string `structs:"comment" json:"comment"`
@ -38,6 +42,15 @@ func (pls Playlist) IsSmartPlaylist() bool {
return pls.Rules != nil && pls.Rules.Expression != nil
}
// RefreshDelay returns the playlist's own refresh window when set, falling
// back to the global SmartPlaylistRefreshDelay.
func (pls Playlist) RefreshDelay() time.Duration {
if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 {
return pls.Rules.RefreshDelay
}
return conf.Server.SmartPlaylistRefreshDelay
}
func (pls Playlist) MediaFiles() MediaFiles {
if len(pls.Tracks) == 0 {
return nil
@ -187,14 +200,18 @@ func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPa
type Playlists []Playlist
type PlaylistCursor iter.Seq2[Playlist, error]
type PlaylistRepository interface {
ResourceRepository
AnnotatedRepository
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(pls *Playlist, cols ...string) error
Get(id string) (*Playlist, error)
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
GetCursor(options ...QueryOptions) (PlaylistCursor, error)
FindByPath(path string) (*Playlist, error)
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
@ -218,10 +235,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles {
return mfs
}
type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error]
type PlaylistTrackRepository interface {
ResourceRepository
CountAll(options ...QueryOptions) (int64, error)
GetAll(options ...QueryOptions) (PlaylistTracks, error)
GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error)
GetAlbumIDs(options ...QueryOptions) ([]string, error)
GetMediaFileIDs(options ...QueryOptions) ([]string, error)
Add(mediaFileIds []string) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)

View File

@ -1,6 +1,10 @@
package model_test
import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/tests"
@ -45,6 +49,31 @@ var _ = Describe("Playlist", func() {
})
})
Describe("RefreshDelay", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second
})
It("returns the global config value when rules have no refreshDelay", func() {
pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}}
Expect(pls.RefreshDelay()).To(Equal(5 * time.Second))
})
It("returns the per-playlist value when set", func() {
pls := model.Playlist{Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Is{"loved": true}},
RefreshDelay: 24 * time.Hour,
}}
Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour))
})
It("returns the global value for non-smart playlists", func() {
pls := model.Playlist{}
Expect(pls.RefreshDelay()).To(Equal(5 * time.Second))
})
})
Describe("NormalizeChildPaths()", func() {
It("normalizes file paths", func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)")

View File

@ -3,11 +3,17 @@ package model
import "time"
type Scrobble struct {
MediaFileID string
UserID string
SubmissionTime time.Time
ID int64 `structs:"id" json:"id"`
MediaFileID string `structs:"media_file_id" json:"mediaFileId"`
UserID string `json:"-"`
SubmissionTime int64 `structs:"submission_time" json:"submissionTime"`
}
type ScrobbleRepository interface {
CountAll(options ...QueryOptions) (int64, error)
Get(id string) (*Scrobble, error)
GetAll(options ...QueryOptions) (Scrobbles, error)
RecordScrobble(mediaFileID string, submissionTime time.Time) error
}
type Scrobbles []Scrobble

View File

@ -20,4 +20,5 @@ type ScrobbleBufferRepository interface {
Next(service string, userId string) (*ScrobbleEntry, error)
Dequeue(entry *ScrobbleEntry) error
Length() (int64, error)
Discard(service string) error
}

View File

@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) {
type TagRepository interface {
Add(libraryID int, tags ...Tag) error
UpdateCounts() error
GetAll(name TagName, options ...QueryOptions) (TagList, error)
}
type TagName string

View File

@ -3,6 +3,7 @@ package persistence
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"maps"
@ -31,6 +32,10 @@ type dbAlbum struct {
Participants string `structs:"-" json:"-"`
Tags string `structs:"-" json:"-"`
FolderIDs string `structs:"-" json:"-"`
// dbx maps columns to fields by name; RGAlbumGain doesn't convert to
// rg_album_gain, so shim fields carry the read and PostScan copies them over.
RgAlbumGain *float64 `structs:"-" json:"-"`
RgAlbumPeak *float64 `structs:"-" json:"-"`
}
func (a *dbAlbum) PostScan() error {
@ -58,6 +63,8 @@ func (a *dbAlbum) PostScan() error {
}
a.Album.FolderIDs = ids
}
a.Album.RGAlbumGain = a.RgAlbumGain
a.Album.RGAlbumPeak = a.RgAlbumPeak
return nil
}
@ -247,6 +254,29 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
return res.toModels(), nil
}
func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) {
sq := r.selectAlbum(options...)
cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq)
if err != nil {
return nil, err
}
return wrapAlbumCursor(cursor), nil
}
func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
cond := And{Gt{"max_year": 0}, Eq{"missing": false}}
if len(libraryIDs) > 0 {
cond = append(cond, Eq{"library_id": libraryIDs})
}
sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year"))
years := []int{}
err := r.queryAllSlice(sq, &years)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
return years, nil
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
var from dbx.NullStringMap
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
@ -319,17 +349,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error)
}
func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor {
return func(yield func(model.Album, error) bool) {
for a, err := range cursor {
if a.Album == nil {
yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err))
return
}
if !yield(*a.Album, err) || err != nil {
return
}
}
}
return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album }))
}
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based

View File

@ -3,6 +3,7 @@ package persistence
import (
"errors"
"fmt"
"sort"
"time"
"github.com/Masterminds/squirrel"
@ -67,6 +68,22 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("GetCursor", func() {
It("yields the same albums as GetAll", func() {
opts := model.QueryOptions{Sort: "name"}
want, err := albumRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want)))
})
It("honors Max/Offset like GetAll", func() {
opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1}
want, err := albumRepo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want)))
})
})
Describe("GetAll", func() {
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
albums, err := albumRepo.GetAll(opts...)
@ -835,6 +852,65 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("GetYears", func() {
It("returns distinct album years ascending, excluding zero", func() {
years, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
// Sorted ascending, no duplicates, no zero-year entries.
Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue())
Expect(years).ToNot(ContainElement(0))
for i := 1; i < len(years); i++ {
Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct
}
})
It("deduplicates repeated years", func() {
// Regression test: verify that DISTINCT is applied in the SQL.
// Insert two albums with the same non-zero max_year (2005).
album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005}
album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005}
Expect(albumRepo.Put(album1)).To(Succeed())
Expect(albumRepo.Put(album2)).To(Succeed())
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}}))
})
years, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
// Count occurrences of 2005 in the result
count := 0
for _, y := range years {
if y == 2005 {
count++
}
}
Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it")
})
It("scopes years to the given libraries", func() {
all, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
// A library with no albums yields no years.
scoped, err := albumRepo.GetYears(99999)
Expect(err).ToNot(HaveOccurred())
Expect(scoped).To(BeEmpty())
Expect(all).ToNot(BeEmpty())
})
It("excludes years that belong only to missing albums", func() {
gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true}
Expect(albumRepo.Put(gone)).To(Succeed())
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"}))
})
years, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
Expect(years).ToNot(ContainElement(1911))
})
})
Describe("wrapAlbumCursor", func() {
It("does not panic when the cursor yields a dbAlbum with nil Album", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:
@ -854,7 +930,7 @@ var _ = Describe("AlbumRepository", func() {
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album"))
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})
@ -874,6 +950,33 @@ var _ = Describe("AlbumRepository", func() {
Expect(albums[0].ID).To(Equal("a1"))
})
})
Describe("ReplayGain", func() {
BeforeEach(func() {
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}}))
})
})
It("round-trips album ReplayGain gain and peak", func() {
Expect(albumRepo.Put(&model.Album{
ID: "rg-1", Name: "rg", LibraryID: 1,
RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98),
})).To(Succeed())
got, err := albumRepo.Get("rg-1")
Expect(err).ToNot(HaveOccurred())
Expect(got.RGAlbumGain).ToNot(BeNil())
Expect(*got.RGAlbumGain).To(Equal(-7.5))
Expect(got.RGAlbumPeak).ToNot(BeNil())
Expect(*got.RGAlbumPeak).To(Equal(0.98))
})
It("reads nil when ReplayGain is unset", func() {
Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed())
got, err := albumRepo.Get("rg-2")
Expect(err).ToNot(HaveOccurred())
Expect(got.RGAlbumGain).To(BeNil())
Expect(got.RGAlbumPeak).To(BeNil())
})
})
})
func _p(id, name string, sortName ...string) model.Participant {

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"iter"
"os"
"slices"
"strings"
@ -263,6 +264,19 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists,
return res, err
}
func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) {
sel := r.selectArtist(options...)
cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel)
if err != nil {
return nil, err
}
return wrapArtistCursor(cursor), nil
}
func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor {
return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist }))
}
func (r *artistRepository) getIndexKey(a model.Artist) string {
source := a.OrderArtistName
if conf.Server.PreferSortTags {

View File

@ -268,6 +268,22 @@ var _ = Describe("ArtistRepository", func() {
repo = NewArtistRepository(ctx, GetDBXBuilder())
})
Describe("GetCursor", func() {
It("yields the same artists as GetAll", func() {
opts := model.QueryOptions{Sort: "name"}
want, err := repo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want)))
})
It("honors Max/Offset like GetAll", func() {
opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1}
want, err := repo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want)))
})
})
Describe("Basic Operations", func() {
Describe("Count", func() {
It("returns the number of artists in the DB", func() {

View File

@ -263,17 +263,7 @@ func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) {
}
func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
return func(yield func(model.Folder, error) bool) {
for f, err := range cursor {
if f.Folder == nil {
yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err))
return
}
if !yield(*f.Folder, err) || err != nil {
return
}
}
}
return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder }))
}
func (r folderRepository) purgeEmpty(libraryIDs ...int) error {

View File

@ -297,7 +297,7 @@ var _ = Describe("FolderRepository", func() {
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder"))
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})

View File

@ -173,15 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error {
Set("last_scan_started_at", time.Time{}).
Where(Eq{"id": id})
_, err := r.executeSQL(sq)
if err != nil {
return err
}
// https://www.sqlite.org/pragma.html#pragma_optimize
// Use mask 0x10000 to check table sizes without running ANALYZE
// Running ANALYZE can cause query planner issues with expression-based collation indexes
if conf.Server.DevOptimizeDB {
_, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
}
return err
}

View File

@ -420,17 +420,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
}
func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor {
return func(yield func(model.MediaFile, error) bool) {
for m, err := range cursor {
if m.MediaFile == nil {
yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err))
return
}
if !yield(*m.MediaFile, err) || err != nil {
return
}
}
}
return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile }))
}
// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries

View File

@ -29,6 +29,22 @@ var _ = Describe("MediaRepository", func() {
mr = NewMediaFileRepository(ctx, GetDBXBuilder())
})
Describe("GetCursor", func() {
It("yields the same media files as GetAll", func() {
opts := model.QueryOptions{Sort: "title"}
want, err := mr.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want)))
})
It("honors Max/Offset like GetAll", func() {
opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1}
want, err := mr.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want)))
})
})
It("gets mediafile from the DB", func() {
actual, err := mr.Get("1004")
Expect(err).ToNot(HaveOccurred())
@ -1012,7 +1028,7 @@ var _ = Describe("MediaRepository", func() {
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile"))
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})

View File

@ -123,6 +123,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository
return s.Tag(ctx).(model.ResourceRepository)
case model.Plugin:
return s.Plugin(ctx).(model.ResourceRepository)
case model.Scrobble:
return s.Scrobble(ctx).(model.ResourceRepository)
}
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
return nil
@ -191,6 +193,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }),
trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }),
trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }),
trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }),
trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }),

View File

@ -4,6 +4,7 @@ import (
"context"
"path/filepath"
"testing"
"time"
"github.com/Masterminds/squirrel"
_ "github.com/mattn/go-sqlite3"
@ -157,6 +158,13 @@ var (
testUsers = model.Users{adminUser, regularUser, thirdUser}
)
var (
firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble}
)
func p(path string) string {
return filepath.FromSlash(path)
}
@ -304,8 +312,33 @@ var _ = BeforeSuite(func() {
songComeTogether.Starred = true
songComeTogether.StarredAt = mf.StarredAt
testSongs[1] = songComeTogether
scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository)
for _, s := range scrobbles {
_, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{
"media_file_id": s.MediaFileID,
"user_id": s.UserID,
"submission_time": s.SubmissionTime,
}))
if err != nil {
panic(err)
}
}
})
func GetDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Dialect)
}
// collectCursor takes the cursor's underlying func type so the named cursor types
// (model.AlbumCursor, ...) infer T.
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
GinkgoHelper()
Expect(err).ToNot(HaveOccurred())
var out []T
for item, err := range cursor {
Expect(err).ToNot(HaveOccurred())
out = append(out, item)
}
return out
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"iter"
"slices"
"time"
@ -50,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
r.ctx = ctx
r.db = db
r.registerModel(&model.Playlist{}, map[string]filterFunc{
"q": playlistFilter,
"smart": smartPlaylistFilter,
"id": idFilter("playlist"),
"q": playlistFilter,
"smart": smartPlaylistFilter,
"starred": annotationBoolFilter("starred"),
})
r.setSortMappings(map[string]string{
"owner_name": "owner_name",
@ -85,8 +88,11 @@ func (r *playlistRepository) userFilter() Sqlizer {
}
func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
sq := Select().Where(r.userFilter())
return r.count(sq, options...)
query := Select().Where(r.userFilter())
if filtersNeedAnnotation(r.applyFilters(query, options...)) {
query = r.withAnnotation(query, "playlist.id")
}
return r.count(query, options...)
}
func (r *playlistRepository) Exists(id string) (bool, error) {
@ -183,6 +189,21 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
return playlists, err
}
func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) {
// Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists.
sel := r.selectPlaylist(options...).Where(r.userFilter())
cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel)
if err != nil {
return nil, err
}
return wrapPlaylistCursor(cursor), nil
}
// dbPlaylist embeds a value, not a pointer, so its model is never nil.
func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor {
return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist }))
}
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
@ -203,8 +224,9 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists,
}
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).Join("user on user.id = owner_id").
sel := r.newSelect(options...).Join("user on user.id = owner_id").
Columns(r.tableName+".*", "user.user_name as owner_name")
return r.withAnnotation(sel, r.tableName+".id")
}
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
@ -278,10 +300,11 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
return nil
}
func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) {
sel = r.applyLibraryFilter(sel, "f")
// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically.
func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder {
query = r.applyLibraryFilter(query, "f")
userID := loggedUser(r.ctx).ID
tracksQuery := sel.
return query.
Columns(
"coalesce(starred, 0) as starred",
"starred_at",
@ -301,8 +324,11 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
Join("media_file f on f.id = media_file_id").
Join("library on f.library_id = library.id").
Where(Eq{"playlist_id": id})
}
func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) {
tracks := dbPlaylistTracks{}
err := r.queryAll(tracksQuery, &tracks)
err := r.queryAll(r.tracksQuery(query, id), &tracks)
if err != nil {
return nil, err
}

View File

@ -1,11 +1,16 @@
package persistence
import (
"slices"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository", func() {
@ -23,6 +28,15 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("GetCursor", func() {
It("yields the same playlists as GetAll", func() {
opts := model.QueryOptions{Sort: "name"}
want, err := repo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want)))
})
})
Describe("Exists", func() {
It("returns true for an existing playlist", func() {
Expect(repo.Exists(plsCool.ID)).To(BeTrue())
@ -71,6 +85,139 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Annotations", func() {
var plsID string
BeforeEach(func() {
pls := model.Playlist{Name: "Annotated", OwnerID: "userid"}
Expect(repo.Put(&pls)).To(Succeed())
plsID = pls.ID
})
countAnnotations := func() int {
var count int
Expect(GetDBXBuilder().NewQuery(
"SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}").
Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed())
return count
}
It("stores and reads back starred", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
p, err := repo.Get(plsID)
Expect(err).ToNot(HaveOccurred())
Expect(p.Starred).To(BeTrue())
Expect(p.StarredAt).ToNot(BeNil())
})
It("stores and reads back rating and average_rating", func() {
Expect(repo.SetRating(4, plsID)).To(Succeed())
p, err := repo.Get(plsID)
Expect(err).ToNot(HaveOccurred())
Expect(p.Rating).To(Equal(4))
Expect(p.RatedAt).ToNot(BeNil())
Expect(p.AverageRating).To(Equal(4.0))
})
It("keeps annotations isolated per user", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()),
model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true})
otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder())
p, err := otherRepo.Get(plsID)
Expect(err).ToNot(HaveOccurred())
Expect(p.Starred).To(BeFalse())
})
It("reads starred back through GetAll", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID })
Expect(idx).To(BeNumerically(">=", 0))
Expect(all[idx].Starred).To(BeTrue())
})
It("counts playlists using annotation filters", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}}
starred, err := repo.GetAll(options)
Expect(err).ToNot(HaveOccurred())
Expect(starred).To(ContainElement(HaveField("ID", plsID)))
count, err := repo.CountAll(options)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(len(starred))))
})
It("filters starred playlists through the registered REST filter", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "true"},
})
Expect(err).ToNot(HaveOccurred())
starred := res.(model.Playlists)
Expect(starred).To(ContainElement(HaveField("ID", plsID)))
for _, p := range starred {
Expect(p.Starred).To(BeTrue())
}
res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "false"},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID)))
})
It("reads a playlist by id through the REST id filter without ambiguity", func() {
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"id": plsID},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID)))
})
It("does not leak an annotation row of another item_type sharing the playlist id", func() {
// Older builds (and the star fallthrough) can leave a media_file-typed row
// under a playlist id; the item_type-scoped join must not surface or dupe it.
_, err := GetDBXBuilder().NewQuery(
"INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)").
Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute()
Expect(err).ToNot(HaveOccurred())
p, err := repo.Get(plsID)
Expect(err).ToNot(HaveOccurred())
Expect(p.Starred).To(BeFalse())
all, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
matches := 0
for _, pl := range all {
if pl.ID == plsID {
matches++
}
}
Expect(matches).To(Equal(1))
})
It("relies on the annotation sweep, not Delete, to clean up annotations", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
Expect(repo.Delete(plsID)).To(Succeed())
Expect(countAnnotations()).To(Equal(1))
Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed())
Expect(countAnnotations()).To(Equal(0))
})
})
It("Put/Exists/Delete", func() {
By("saves the playlist to the DB")
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}

View File

@ -77,6 +77,14 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
return p
}
func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := Select().
Join("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
return r.count(query, options...)
}
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
query := Select().
LeftJoin("media_file f on f.id = media_file_id").
@ -116,6 +124,30 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P
return tracks, err
}
func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) {
sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId)
cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel)
if err != nil {
return nil, err
}
return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack {
return t.PlaylistTrack
})), nil
}
// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data.
func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) {
query := r.newSelect(options...).Columns("media_file_id").
Join("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
var ids []string
if err := r.queryAllSlice(query, &ids); err != nil {
return nil, err
}
return ids, nil
}
func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) {
query := r.newSelect(options...).Columns("distinct mf.album_id").
Join("media_file mf on mf.id = media_file_id").

View File

@ -0,0 +1,61 @@
package persistence
import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PlaylistTrackRepository", func() {
var repo model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true)
})
Describe("GetCursor", func() {
It("yields the same tracks as GetAll", func() {
opts := model.QueryOptions{Sort: "id"}
want, err := repo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).To(HaveLen(2))
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want)))
})
It("honors Max and Offset", func() {
opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1}
want, err := repo.GetAll(opts)
Expect(err).ToNot(HaveOccurred())
Expect(want).To(HaveLen(1))
Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want)))
})
})
Describe("CountAll", func() {
It("returns the number of tracks in the playlist", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
})
It("ignores Max and Offset", func() {
Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2)))
})
})
Describe("GetMediaFileIDs", func() {
It("returns the song ids in playlist order", func() {
Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})).
To(Equal([]string{songDayInALife.ID, songRadioactivity.ID}))
})
It("honors Max and Offset", func() {
Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})).
To(Equal([]string{songRadioactivity.ID}))
})
})
})

View File

@ -93,6 +93,10 @@ func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error {
return r.delete(Eq{"id": entry.ID})
}
func (r *scrobbleBufferRepository) Discard(service string) error {
return r.delete(Eq{"service": service})
}
func (r *scrobbleBufferRepository) Length() (int64, error) {
return r.count(Select())
}

View File

@ -191,6 +191,28 @@ var _ = Describe("ScrobbleBufferRepository", func() {
})
Describe("Discard", func() {
It("deletes all entries for a service, keeping other services intact", func() {
Expect(scrobble.Discard("a")).To(Succeed())
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(1)))
entry, err := scrobble.Next("b", "2222")
Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil())
})
It("is a no-op for a service without entries", func() {
Expect(scrobble.Discard("nonexistent")).To(Succeed())
count, err := scrobble.Length()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(4)))
})
})
Describe("UserIds", func() {
It("should return ordered list for services", func() {
ids, err := scrobble.UserIDs("a")

View File

@ -5,6 +5,7 @@ import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
@ -13,11 +14,34 @@ type scrobbleRepository struct {
sqlRepository
}
func fromTs(_ string, value any) Sqlizer {
return GtOrEq{"scrobbles.submission_time": value}
}
func toTs(_ string, value any) Sqlizer {
return LtOrEq{"scrobbles.submission_time": value}
}
func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder {
user := loggedUser(r.ctx)
return r.newSelect(options...).
Columns("id", "media_file_id", "submission_time").
Where(Eq{"scrobbles.user_id": user.ID})
}
func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
r := &scrobbleRepository{}
r.ctx = ctx
r.db = db
r.tableName = "scrobbles"
r.registerModel(&model.Scrobble{}, map[string]filterFunc{
"from": fromTs,
"to": toTs,
})
r.setSortMappings(map[string]string{
"submission_time": "submission_time",
})
return r
}
@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t
_, err := r.executeSQL(insert)
return err
}
func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(r.baseQuery(), options...)
}
func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) {
sel := r.baseQuery().Where(Eq{"id": id})
var res model.Scrobble
err := r.queryOne(sel, &res)
return &res, err
}
func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) {
sel := r.baseQuery(options...)
var scrobbles model.Scrobbles
err := r.queryAll(sel, &scrobbles)
return scrobbles, err
}
func (r *scrobbleRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *scrobbleRepository) EntityName() string {
return "scrobble"
}
func (r *scrobbleRepository) NewInstance() any {
return &model.Scrobble{}
}
var _ model.ScrobbleRepository = (*scrobbleRepository)(nil)
var _ model.ResourceRepository = (*scrobbleRepository)(nil)

View File

@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@ -15,32 +16,33 @@ import (
var _ = Describe("ScrobbleRepository", func() {
var repo model.ScrobbleRepository
var rawRepo sqlRepository
var ctx context.Context
var fileID string
var userID string
BeforeEach(func() {
fileID = id.NewRandom()
userID = id.NewRandom()
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
db := GetDBXBuilder()
repo = NewScrobbleRepository(ctx, db)
rawRepo = sqlRepository{
ctx: ctx,
tableName: "scrobbles",
db: db,
}
})
AfterEach(func() {
_, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
_, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
_, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
})
Describe("RecordScrobble", func() {
var fileID string
var userID string
var rawRepo sqlRepository
BeforeEach(func() {
fileID = id.NewRandom()
userID = id.NewRandom()
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
db := GetDBXBuilder()
repo = NewScrobbleRepository(ctx, db)
rawRepo = sqlRepository{
ctx: ctx,
tableName: "scrobbles",
db: db,
}
})
AfterEach(func() {
_, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
_, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
_, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
})
It("records a scrobble event", func() {
submissionTime := time.Now().UTC()
@ -81,4 +83,137 @@ var _ = Describe("ScrobbleRepository", func() {
Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
})
})
Context("admin user (id userid)", func() {
BeforeEach(func() {
ctx = request.WithUser(log.NewContext(context.TODO()), adminUser)
repo = NewScrobbleRepository(ctx, GetDBXBuilder())
})
Describe("Count", func() {
It("Returns the number of scrobbles in the DB for admin user", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
})
It("returns scrobbles in a range", func() {
Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1)))
})
})
Describe("Get", func() {
It("returns an existing scrobble for the user", func() {
scrobble, err := repo.Get("1")
Expect(err).To(BeNil())
Expect(scrobble.ID).To(Equal(int64(1)))
Expect(scrobble.MediaFileID).To(Equal("1001"))
Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime))
})
It("does not return a scrobble that exists for another user", func() {
_, err := repo.Get("2")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not return a scrobble that does not exist", func() {
_, err := repo.Get("444")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
It("returns all scrobbles in reverse order", func() {
scrobbles, err := repo.GetAll(model.QueryOptions{
Sort: "submission_time",
Order: "DESC",
})
Expect(err).To(BeNil())
Expect(scrobbles).To(HaveLen(2))
Expect(scrobbles[0].ID).To(Equal(int64(3)))
Expect(scrobbles[0].MediaFileID).To(Equal("1002"))
Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime))
Expect(scrobbles[1].ID).To(Equal(int64(1)))
Expect(scrobbles[1].MediaFileID).To(Equal("1001"))
Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime))
})
It("returns scrobbles in a range", func() {
scrobbles, err := repo.GetAll(model.QueryOptions{
Filters: squirrel.GtOrEq{"submission_time": 1}})
Expect(err).To(BeNil())
Expect(scrobbles).To(HaveLen(1))
Expect(scrobbles[0].ID).To(Equal(int64(3)))
Expect(scrobbles[0].MediaFileID).To(Equal("1002"))
Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime))
})
})
})
Context("non-admin user", func() {
BeforeEach(func() {
ctx = request.WithUser(log.NewContext(context.TODO()), regularUser)
repo = NewScrobbleRepository(ctx, GetDBXBuilder())
})
Describe("Count", func() {
It("Returns the number of scrobbles in the DB for admin user", func() {
Expect(repo.CountAll()).To(Equal(int64(1)))
})
It("returns scrobbles in a range", func() {
Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0)))
})
})
Describe("Get", func() {
It("returns an existing scrobble for the user", func() {
scrobble, err := repo.Get("2")
Expect(err).To(BeNil())
Expect(scrobble.ID).To(Equal(int64(2)))
Expect(scrobble.MediaFileID).To(Equal("1003"))
Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
})
It("does not return a scrobble that exists for another user", func() {
_, err := repo.Get("1")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not return a scrobble that does not exist", func() {
_, err := repo.Get("444")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
It("returns all scrobbles in reverse order", func() {
scrobbles, err := repo.GetAll(model.QueryOptions{
Sort: "submission_time",
Order: "DESC",
})
Expect(err).To(BeNil())
Expect(scrobbles).To(HaveLen(1))
Expect(scrobbles[0].ID).To(Equal(int64(2)))
Expect(scrobbles[0].MediaFileID).To(Equal("1003"))
Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
})
It("returns scrobbles in a range", func() {
scrobbles, err := repo.GetAll(model.QueryOptions{
Filters: squirrel.GtOrEq{"submission_time": 1}})
Expect(err).To(BeNil())
Expect(scrobbles).To(HaveLen(1))
Expect(scrobbles[0].ID).To(Equal(int64(2)))
Expect(scrobbles[0].MediaFileID).To(Equal("1003"))
Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
})
})
})
})

View File

@ -4,7 +4,6 @@ import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
@ -78,7 +77,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr
if !pls.IsSmartPlaylist() {
return false
}
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay {
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() {
return false
}
if pls.OwnerID != usr.ID {

View File

@ -162,6 +162,50 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
Context("per-playlist refreshDelay", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
evaluatedAt := time.Now().Add(-1 * time.Hour)
rules := &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
RefreshDelay: 24 * time.Hour,
}
pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt}
Expect(repo.Put(&pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
got, err := repo.GetWithTracks(pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Not re-evaluated: EvaluatedAt unchanged, no tracks materialized
Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
Expect(got.Tracks).To(BeEmpty())
})
It("refreshes when the per-playlist delay has elapsed, even if global has not", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
evaluatedAt := time.Now().Add(-10 * time.Minute)
rules := &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
RefreshDelay: 5 * time.Minute,
}
pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt}
Expect(repo.Put(&pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
got, err := repo.GetWithTracks(pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
Expect(got.Tracks).To(HaveLen(1))
Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
})
})
})
})

View File

@ -67,7 +67,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
query = query.
LeftJoin("annotation on ("+
"annotation.item_id = "+idField+
" AND annotation.user_id = '"+userID+"')").
" AND annotation.item_type = ?"+
" AND annotation.user_id = ?)", r.tableName, userID).
Columns(
"coalesce(starred, 0) as starred",
"coalesce(rating, 0) as rating",

View File

@ -347,6 +347,24 @@ func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
return err
}
// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's
// embedded model, which a type parameter can't reach on its own.
func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
for row, err := range cursor {
m := toModel(row)
if m == nil {
var zero T
yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err))
return
}
if !yield(*m, err) || err != nil {
return
}
}
}
}
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {

View File

@ -48,6 +48,7 @@ func marshalTags(tags model.Tags) string {
return string(res)
}
// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "<name>_id" key maps to "$.<name>".
func tagIDFilter(name string, idValue any) Sqlizer {
name = strings.TrimSuffix(name, "_id")
return Exists(

View File

@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
return nil
}
func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) {
sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name})
res := model.TagList{}
err := r.queryAll(sq, &res)
return res, err
}
func (r *tagRepository) purgeUnused() error {
del := Delete(r.tableName).Where(`
del := Delete(r.tableName).Where(`
id not in (select jt.value
from album left join json_tree(album.tags, '$') as jt
where atom is not null
and key = 'id'
UNION
UNION
select jt.value
from media_file left join json_tree(media_file.tags, '$') as jt
where atom is not null

View File

@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example:
**Required fields:** `name`, `author`, `version`
**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
**Optional fields:** `description`, `website`, `config`, `permissions`
#### Config Definition
@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema
}
```
#### Experimental Features
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
- **`threads`** Enables WebAssembly threads support (for plugins compiled with multi-threading)
```json
{
"experimental": {
"threads": {
"reason": "Required for concurrent audio processing"
}
}
}
```
> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary.
---
## Capabilities

View File

@ -82,7 +82,8 @@ type taskQueueServiceImpl struct {
}
// newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database.
func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) {
// The given ctx bounds the service's background work (queue workers, cleanup loop).
func newTaskQueueService(ctx context.Context, pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) {
dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
@ -102,7 +103,7 @@ func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int
return nil, fmt.Errorf("creating taskqueue schema: %w", err)
}
ctx, cancel := context.WithCancel(manager.ctx) //nolint:gosec // cancel is stored in struct and called in Close()
ctx, cancel := context.WithCancel(ctx) //nolint:gosec // cancel is stored in struct and called in Close()
s := &taskQueueServiceImpl{
pluginName: pluginName,

View File

@ -42,15 +42,11 @@ var _ = Describe("TaskQueueService", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(tmpDir)
// Create a mock manager with context
managerCtx, cancel := context.WithCancel(ctx)
manager = &Manager{
plugins: make(map[string]*plugin),
ctx: managerCtx,
}
DeferCleanup(cancel)
service, err = newTaskQueueService("test_plugin", manager, 5)
service, err = newTaskQueueService(ctx, "test_plugin", manager, 5)
Expect(err).ToNot(HaveOccurred())
})
@ -730,14 +726,11 @@ var _ = Describe("TaskQueueService", func() {
service.Close()
// Create a new service pointing to the same DB
managerCtx2, cancel2 := context.WithCancel(ctx)
DeferCleanup(cancel2)
manager2 := &Manager{
plugins: make(map[string]*plugin),
ctx: managerCtx2,
}
service, err = newTaskQueueService("test_plugin", manager2, 5)
service, err = newTaskQueueService(ctx, "test_plugin", manager2, 5)
Expect(err).ToNot(HaveOccurred())
// Override callback to succeed
@ -775,14 +768,11 @@ var _ = Describe("TaskQueueService", func() {
Describe("Plugin isolation", func() {
It("uses separate databases for different plugins", func() {
managerCtx2, cancel2 := context.WithCancel(ctx)
DeferCleanup(cancel2)
manager2 := &Manager{
plugins: make(map[string]*plugin),
ctx: managerCtx2,
}
service2, err := newTaskQueueService("other_plugin", manager2, 5)
service2, err := newTaskQueueService(ctx, "other_plugin", manager2, 5)
Expect(err).ToNot(HaveOccurred())
defer service2.Close()

View File

@ -54,6 +54,7 @@ type wsConnection struct {
// webSocketServiceImpl implements host.WebSocketService.
// It provides plugins with WebSocket communication capabilities.
type webSocketServiceImpl struct {
baseCtx context.Context // bounds the read loops, which outlive the Connect() call
pluginName string
manager *Manager
requiredHosts []string
@ -63,8 +64,9 @@ type webSocketServiceImpl struct {
}
// newWebSocketService creates a new WebSocketService for a plugin.
func newWebSocketService(pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl {
func newWebSocketService(ctx context.Context, pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl {
return &webSocketServiceImpl{
baseCtx: ctx,
pluginName: pluginName,
manager: manager,
requiredHosts: permission.RequiredHosts,
@ -129,11 +131,12 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade
s.connections[connectionID] = wsConn
s.mu.Unlock()
// Start read goroutine with manager's context.
// We use manager.ctx instead of the caller's ctx because the readLoop must
// outlive the Connect() call. The manager's context is cancelled during
// application shutdown, ensuring graceful cleanup.
go s.readLoop(s.manager.ctx, connectionID, wsConn)
// Start read goroutine with the service's base context instead of the
// caller's ctx, because the readLoop must outlive the Connect() call.
// Connections are closed by Close() when the plugin is unloaded, which ends
// the readLoop; the base context is a backstop that also ends it on server
// shutdown (it is never cancelled in one-shot CLI runs).
go s.readLoop(s.baseCtx, connectionID, wsConn)
log.Debug(ctx, "WebSocket connected", "plugin", s.pluginName, "connectionID", connectionID, "url", urlStr)
return connectionID, nil

View File

@ -14,6 +14,10 @@ const (
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
)
// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch
// lyrics for whole queues, and the resulting burst can rate-limit upstream providers.
const maxConcurrentLyricsCalls = 2
func init() {
registerCapability(
CapabilityLyrics,
@ -34,6 +38,12 @@ type LyricsPlugin struct {
// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response
// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain).
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
select {
case l.plugin.lyricsSem <- struct{}{}:
defer func() { <-l.plugin.lyricsSem }()
case <-ctx.Done():
return nil, ctx.Err()
}
req := capabilities.GetLyricsRequest{
Track: mediaFileToTrackInfo(l.plugin, mf),
}

View File

@ -3,6 +3,8 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -71,6 +73,45 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
Expect(result[0].Lang).To(Equal("xxx"))
})
It("blocks new calls while the per-plugin concurrency cap is saturated", func() {
sem := provider.plugin.lyricsSem
for range cap(sem) {
sem <- struct{}{}
}
ctx := GinkgoT().Context()
track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"}
done := make(chan error, 1)
go func() {
_, err := provider.GetLyrics(ctx, track)
done <- err
}()
Consistently(done, "500ms").ShouldNot(Receive())
<-sem // free one slot; the pending call should now proceed
Eventually(done).Should(Receive(BeNil()))
for range cap(sem) - 1 {
<-sem
}
})
It("gives up waiting for a slot when the context is cancelled", func() {
sem := provider.plugin.lyricsSem
for range cap(sem) {
sem <- struct{}{}
}
defer func() {
for range cap(sem) {
<-sem
}
}()
ctx, cancel := context.WithCancel(GinkgoT().Context())
cancel()
_, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"})
Expect(err).To(MatchError(context.Canceled))
})
It("returns error when plugin returns error", func() {
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
"test-lyrics": {"error": "service unavailable"},

View File

@ -13,8 +13,6 @@ import (
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"golang.org/x/sync/errgroup"
)
@ -30,11 +28,23 @@ type serviceContext struct {
allLibraries bool // If true, plugin can access all libraries
}
// baseCtx returns the manager's lifecycle context, for host services that
// outlive the plugin call that created them. It falls back to
// context.Background() when the manager was never started, which is the case
// for CLI commands (e.g. `navidrome plugin enable`) that load plugins without
// calling Start.
func (c *serviceContext) baseCtx() context.Context {
if c.manager.ctx == nil {
return context.Background()
}
return c.manager.ctx
}
// hostServiceEntry defines a host service for table-driven registration.
type hostServiceEntry struct {
name string
hasPermission func(*Permissions) bool
create func(*serviceContext) ([]extism.HostFunction, io.Closer)
create func(*serviceContext) ([]extism.HostFunction, io.Closer, error)
}
// hostServices defines all available host services.
@ -43,119 +53,117 @@ var hostServices = []hostServiceEntry{
{
name: "Config",
hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newConfigService(ctx.pluginName, ctx.config)
return host.RegisterConfigHostFunctions(service), nil
return host.RegisterConfigHostFunctions(service), nil, nil
},
},
{
name: "SubsonicAPI",
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers))
return host.RegisterSubsonicAPIHostFunctions(service), nil
return host.RegisterSubsonicAPIHostFunctions(service), nil, nil
},
},
{
name: "Scheduler",
hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance())
return host.RegisterSchedulerHostFunctions(service), service
return host.RegisterSchedulerHostFunctions(service), service, nil
},
},
{
name: "WebSocket",
hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Websocket
service := newWebSocketService(ctx.pluginName, ctx.manager, perm)
return host.RegisterWebSocketHostFunctions(service), service
service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm)
return host.RegisterWebSocketHostFunctions(service), service, nil
},
},
{
name: "Artwork",
hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newArtworkService()
return host.RegisterArtworkHostFunctions(service), nil
return host.RegisterArtworkHostFunctions(service), nil, nil
},
},
{
name: "Cache",
hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newCacheService(ctx.pluginName)
return host.RegisterCacheHostFunctions(service), service
return host.RegisterCacheHostFunctions(service), service, nil
},
},
{
name: "Library",
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Library
service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries)
return host.RegisterLibraryHostFunctions(service), nil
return host.RegisterLibraryHostFunctions(service), nil, nil
},
},
{
name: "KVStore",
hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Kvstore
service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm)
service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm)
if err != nil {
log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err)
return nil, nil
return nil, nil, err
}
return host.RegisterKVStoreHostFunctions(service), service
return host.RegisterKVStoreHostFunctions(service), service, nil
},
},
{
name: "Users",
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterUsersHostFunctions(service), nil
return host.RegisterUsersHostFunctions(service), nil, nil
},
},
{
name: "Matcher",
hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem
service := newMatcherService(
ctx.manager.ds, hasFilesystemPerm,
newUserAccess(ctx.allowedUsers, ctx.allUsers),
newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries),
)
return host.RegisterMatcherHostFunctions(service), nil
return host.RegisterMatcherHostFunctions(service), nil, nil
},
},
{
name: "HTTP",
hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Http
service := newHTTPService(ctx.pluginName, perm)
return host.RegisterHTTPHostFunctions(service), nil
return host.RegisterHTTPHostFunctions(service), nil, nil
},
},
{
name: "Task",
hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
perm := ctx.permissions.Taskqueue
maxConcurrency := int32(1)
if perm.MaxConcurrency > 0 {
maxConcurrency = int32(perm.MaxConcurrency)
}
service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency)
service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency)
if err != nil {
log.Error("Failed to create Task service", "plugin", ctx.pluginName, err)
return nil, nil
return nil, nil, err
}
return host.RegisterTaskHostFunctions(service), service
return host.RegisterTaskHostFunctions(service), service, nil
},
},
}
@ -256,6 +264,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
// loadPluginWithConfig loads a plugin with configuration from DB.
// The p.Path should point to an .ndp package file.
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// NewContext falls back to context.Background() when m.ctx is nil (unstarted manager)
ctx := log.NewContext(m.ctx, "plugin", p.ID)
if m.stopped.Load() {
@ -328,6 +337,15 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
// Build host functions based on permissions from manifest
var hostFunctions []extism.HostFunction
var closers []io.Closer
loaded := false
// On success the closers are owned by the registered plugin; on any
// failure past this point, close them so partially-created services
// don't leak goroutines or file handles.
defer func() {
if !loaded {
closeAll(closers)
}
}()
svcCtx := &serviceContext{
pluginName: p.ID,
@ -341,7 +359,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
for _, entry := range hostServices {
if entry.hasPermission(pkg.Manifest.Permissions) {
funcs, closer := entry.create(svcCtx)
funcs, closer, err := entry.create(svcCtx)
if err != nil {
return fmt.Errorf("creating %s service: %w", entry.name, err)
}
hostFunctions = append(hostFunctions, funcs...)
if closer != nil {
closers = append(closers, closer)
@ -354,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
WithCompilationCache(m.cache).
WithCloseOnContextDone(true)
// Enable experimental threads if requested in manifest
if pkg.Manifest.HasExperimentalThreads() {
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
log.Debug(ctx, "Enabling experimental threads support")
}
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: runtimeConfig,
@ -398,8 +413,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
}
m.mu.Unlock()
loaded = true
// Call plugin init function
callPluginInit(ctx, m.plugins[p.ID])
@ -407,6 +424,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return nil
}
// closeAll closes host service closers accumulated before a load failure,
// so partially-created services don't leak goroutines or file handles.
func closeAll(closers []io.Closer) {
for _, c := range closers {
_ = c.Close()
}
}
// parsePluginConfig parses a JSON config string into a map of string values.
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
func parsePluginConfig(configJSON string) (map[string]string, error) {

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