mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge branch 'master' into feat/support-playlist-paths
# Conflicts: # model/playlist.go # model/playlist_test.go
This commit is contained in:
commit
a7cd940d55
6
.github/FUNDING.yml
vendored
6
.github/FUNDING.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/download-link-on-pr.yml
vendored
2
.github/workflows/download-link-on-pr.yml
vendored
@ -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
|
||||
|
||||
40
.github/workflows/pipeline.yml
vendored
40
.github/workflows/pipeline.yml
vendored
@ -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
150
.github/workflows/validate-migrations.sh
vendored
Executable 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"
|
||||
29
Dockerfile
29
Dockerfile
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
25
cmd/root.go
25
cmd/root.go
@ -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
|
||||
}
|
||||
|
||||
37
cmd/scan.go
37
cmd/scan.go
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
23
cmd/svc.go
23
cmd/svc.go
@ -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
55
cmd/svc_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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() {
|
||||
|
||||
52
db/db.go
52
db/db.go
@ -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...)) }
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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
|
||||
41
db/migrations/20260719005427_add_album_replaygain.go
Normal file
41
db/migrations/20260719005427_add_album_replaygain.go
Normal 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
|
||||
}
|
||||
@ -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
224
db/optimize.go
Normal 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
162
db/optimize_test.go
Normal 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
42
go.mod
@ -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
102
go.sum
@ -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=
|
||||
|
||||
@ -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]+",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]"),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
40
model/get_entity_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
})
|
||||
|
||||
|
||||
@ -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() }),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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").
|
||||
|
||||
61
persistence/playlist_track_repository_test.go
Normal file
61
persistence/playlist_track_repository_test.go
Normal 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}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user