diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 076f963d4..5b421331b 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -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 diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f858a5a7..8e6e8126a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -24,7 +24,7 @@ jobs: git_tag: ${{ steps.git-version.outputs.GIT_TAG }} git_sha: ${{ steps.git-version.outputs.GIT_SHA }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true @@ -32,7 +32,7 @@ jobs: - name: Show git version info run: | echo "git describe (dirty): $(git describe --dirty --always --tags)" - echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)" + echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)" echo "git tag: $(git tag --sort=-committerdate | head -n 1)" echo "github_ref: $GITHUB_REF" echo "github_head_sha: ${{ github.event.pull_request.head.sha }}" @@ -40,7 +40,7 @@ jobs: - name: Determine git current SHA and latest tag id: git-version run: | - GIT_TAG=$(git tag --sort=-committerdate | head -n 1) + GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) if [ -n "$GIT_TAG" ]; then if [[ "$GITHUB_REF" != refs/tags/* ]]; then GIT_TAG=${GIT_TAG}-SNAPSHOT @@ -62,7 +62,7 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -96,12 +96,33 @@ jobs: exit 1 fi + validate-migrations: + name: Validate DB migrations + runs-on: ubuntu-latest + # PR-only gate is at step level: a job-level skip would propagate through + # the needs chain (actions/runner#491) and skip all release jobs on tag pushes. + steps: + - uses: actions/checkout@v7 + if: github.event_name == 'pull_request' + with: + fetch-depth: 0 + # Refresh the base branch so the check compares against its CURRENT tip, + # not the (possibly stale) commit the PR was opened against. + - name: Fetch latest base branch + if: github.event_name == 'pull_request' + run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" + - name: Validate migration ordering and naming + if: github.event_name == 'pull_request' + env: + BASE_REF: origin/${{ github.event.pull_request.base.ref }} + run: ./.github/workflows/validate-migrations.sh + go: name: Test Go code runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v6 + uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -127,7 +148,7 @@ jobs: FFMPEG_VERSION: "7.1" FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-go@v6 with: @@ -145,7 +166,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 @@ -199,7 +220,7 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: 24 @@ -230,7 +251,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - run: | set -e for file in resources/i18n/*.json; do @@ -257,7 +278,7 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled] + needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] @@ -276,7 +297,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -300,6 +321,21 @@ jobs: GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} + - name: Set up QEMU for smoke test + if: env.IS_LINUX == 'true' + uses: docker/setup-qemu-action@v4 + + # The binary is static, so binfmt+qemu runs it directly on the runner. + # Catches startup crashes in cross-compiled binaries before they ship, + # e.g. the broken ifunc relocations on 32-bit arm from issue #5738. + - name: Smoke-test binary + if: env.IS_LINUX == 'true' + run: | + BIN=./output/${{ env.PLATFORM }}/navidrome + chmod +x "$BIN" + "$BIN" --help >/dev/null + echo "OK: ${{ matrix.platform }} binary starts" + - name: Upload Binaries uses: actions/upload-artifact@v7 with: @@ -350,7 +386,7 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download digests uses: actions/download-artifact@v8 @@ -384,7 +420,7 @@ jobs: if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' continue-on-error: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Download digests uses: actions/download-artifact@v8 @@ -437,7 +473,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: @@ -471,7 +507,7 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true @@ -491,7 +527,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: - version: '~> v2' + version: '2.16.0' args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml index f7cf00621..546c64114 100644 --- a/.github/workflows/push-translations.yml +++ b/.github/workflows/push-translations.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 2 diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 8fe0b5379..5db32f3fa 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Get updated translations id: poeditor env: diff --git a/.github/workflows/validate-migrations.sh b/.github/workflows/validate-migrations.sh new file mode 100755 index 000000000..07d05c3a6 --- /dev/null +++ b/.github/workflows/validate-migrations.sh @@ -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= (or make migration-go name=)" + 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= (or make migration-go name=) + 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" diff --git a/.gitignore b/.gitignore index 73475a53a..fc8eaac69 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ AGENTS.md *.wasm *.ndp openspec/ +.agents go.work* -.worktrees/ \ No newline at end of file +.worktrees/ diff --git a/.golangci.yml b/.golangci.yml index 28eb375a5..200fe122f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,6 +13,7 @@ linters: - dogsled - durationcheck - errorlint + - forbidigo - gocritic - gocyclo - goprintffuncname @@ -36,6 +37,14 @@ linters: - G401 - G505 - G115 + forbidigo: + forbid: + - pattern: 'tx\.Exec$' + msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context" + - pattern: 'tx\.Query$' + msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context" + - pattern: 'tx\.QueryRow$' + msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context" govet: enable: - nilness @@ -45,6 +54,9 @@ linters: - gosec path: _test\.go text: "G703" + - path-except: 'db/migrations/' + linters: + - forbidigo generated: lax presets: - comments @@ -56,6 +68,7 @@ linters: - builtin$ - examples$ - node_modules + - _gen\.go$ formatters: exclusions: generated: lax diff --git a/Dockerfile b/Dockerfile index ad1e2a41c..df5df52ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,12 +69,15 @@ RUN --mount=type=bind,source=. \ set -e xx-go --wrap export CGO_ENABLED=1 + BUILD_TAGS=$(./release/build-tags.sh) # -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve. - go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \ + go build -tags="${BUILD_TAGS}" -ldflags="-w -s \ -linkmode=external -extldflags '-latomic' \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome . + # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). + ./release/verify-binary.sh /out/navidrome # Fail the build if the binary is accidentally statically linked: dlopen (and # therefore native libwebp detection) only works with a dynamic interpreter. file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; } @@ -108,11 +111,12 @@ RUN --mount=type=bind,source=. \ --mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \ --mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/go/pkg/mod </dev/null || true # Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler. # So let's use gcc for everything except Darwin. @@ -121,14 +125,25 @@ RUN --mount=type=bind,source=. \ export CXX=$(xx-info)-g++ export LD_EXTRA="-extldflags '-static -latomic'" fi + # GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers + # (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch + # range, making static arm binaries jump to garbage inside glibc's ifunc + # resolution and crash before main() (issue #5738). Link 32-bit arm with LLD, + # which emits correct addends. + if [ "$(xx-info arch)" = "arm" ]; then + export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'" + fi if [ "$(xx-info os)" = "windows" ]; then export EXT=".exe" fi - go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \ + BUILD_TAGS=$(./release/build-tags.sh) + go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome${EXT} . + # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). + ./release/verify-binary.sh /out/navidrome* EOT # Verify if the binary was built for the correct platform and it is statically linked @@ -159,7 +174,6 @@ ENV ND_MUSICFOLDER=/music ENV ND_DATAFOLDER=/data ENV ND_CONFIGFILE=/data/navidrome.toml ENV ND_PORT=4533 -ENV ND_ENABLEWEBPENCODING=true RUN touch /.nddockerenv EXPOSE ${ND_PORT} diff --git a/Makefile b/Makefile index e303017c7..fa0d10475 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ export ND_ENABLEINSIGHTSCOLLECTOR=false ifneq ("$(wildcard .git/HEAD)","") GIT_SHA=$(shell git rev-parse --short HEAD) -GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT +GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT else GIT_SHA=source_archive GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT @@ -113,10 +113,11 @@ wire: check_go_env ##@Development Update Dependency Injection gen: check_go_env ##@Development Run go generate for code generation go generate ./... - cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host - cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust - cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust - cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities + cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust + cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types + cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types + cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types go mod tidy -C plugins/pdk/go .PHONY: gen diff --git a/README.md b/README.md index 0ae5bdfaf..4bc85e6a6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided - Ready to use binaries for all major platforms, including **Raspberry Pi** - Automatically **monitors your library** for changes, importing new files and reloading new metadata + - Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`) - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com) - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps) - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported** diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index 31150c673..d51f65dd9 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -1,7 +1,7 @@ package deezer import ( - bytes "bytes" + "bytes" "context" "encoding/json" "errors" diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index 4a93f5b83..e7dd18ac1 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -8,7 +8,6 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") - return &mf + return new(metadata.ToMediaFile(1, "folderID")) } BeforeEach(func() { @@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() { Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) }, Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), - Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)), ) }) @@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() { DisplayTitle: "", Lang: code, Line: []model.Line{ - {Start: gg.P(int64(0)), Value: "This is"}, - {Start: gg.P(int64(2500)), Value: secondLine}, + {Start: new(int64(0)), Value: "This is"}, + {Start: new(int64(2500)), Value: secondLine}, }, Offset: nil, Synced: true, diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 02c198120..eb8f3d36e 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -231,10 +231,9 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti res := make([]agents.Song, 0, len(resp)) for _, t := range resp { res = append(res, agents.Song{ - Name: t.Name, - MBID: t.MBID, - Artist: t.Artist.Name, - ArtistMBID: t.Artist.MBID, + Name: t.Name, + MBID: t.MBID, + Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}}, }) } return res, nil diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94788b8bd..7e4e29294 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -309,11 +309,11 @@ var _ = Describe("lastfmAgent", func() { f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{ - {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, - {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, - {Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}, - {Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"}, - {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}, + {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, + {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, + {Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}}, + {Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}}, + {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}}, })) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough")) diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 162ae9037..499863e28 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { return } resp["status"] = key != "" + linkToken, err := createLinkToken(u.ID) + if err != nil { + log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + resp["linkToken"] = linkToken _ = rest.RespondWithJSON(w, http.StatusOK, resp) } @@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - uid, err := p.String("uid") + linkToken, err := p.String("uid") if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } + uid, err := verifyLinkToken(linkToken) + if err != nil { + log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token") + return + } // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go new file mode 100644 index 000000000..4cbbd4298 --- /dev/null +++ b/adapters/lastfm/auth_router_test.go @@ -0,0 +1,218 @@ +package lastfm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("auth_router", func() { + var ( + ds *tests.MockDataStore + userProps *tests.MockedUserPropsRepo + httpClient *tests.FakeHttpClient + router *Router + ) + + const ( + victimID = "victim-user-id" + attackerID = "attacker-user-id" + ) + + BeforeEach(func() { + userProps = &tests.MockedUserPropsRepo{} + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedUserProps: userProps, + } + auth.Init(ds) + + httpClient = &tests.FakeHttpClient{} + router = &Router{ + ds: ds, + apiKey: "API_KEY", + secret: "SECRET", + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + router.client = newClient(router.apiKey, router.secret, httpClient) + router.Handler = router.routes() + }) + + storedSessionKey := func(userID string) string { + key, _ := userProps.Get(userID, sessionKeyProperty) + return key + } + + stubGetSessionOK := func(sessionKey string) { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)), + StatusCode: 200, + } + } + + Describe("getLinkStatus", func() { + It("includes a signed linkToken for the authenticated user", func() { + req := httptest.NewRequest(http.MethodGet, "/link", nil) + ctx := request.WithUser(req.Context(), model.User{ID: victimID}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + router.getLinkStatus(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var body map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) + Expect(body["apiKey"]).To(Equal("API_KEY")) + Expect(body["status"]).To(Equal(false)) + token, ok := body["linkToken"].(string) + Expect(ok).To(BeTrue()) + Expect(token).ToNot(BeEmpty()) + + verified, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(verified).To(Equal(victimID)) + }) + }) + + Describe("callback", func() { + It("stores the session key under the user encoded in the signed token", func() { + stubGetSessionOK("LEGIT_SESSION") + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION")) + }) + + It("rejects a raw (unsigned) uid value", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects an expired link token", func() { + expiredToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + "exp": time.Now().Add(-1 * time.Minute).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() { + sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"}) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(attackerID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("writes only under the user encoded in the token, regardless of query manipulation", func() { + // An attacker holds a legitimate link token for their own account. + // They attempt to call the callback hoping to overwrite the victim's + // session key — but the handler must derive the user ID from the + // signed token, not from any other input. + stubGetSessionOK("ATTACKER_SESSION") + attackerToken, err := createLinkToken(attackerID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION")) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + }) + + It("returns 400 when uid is missing", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when token is missing", func() { + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("link token helpers", func() { + It("round-trips a freshly issued token", func() { + token, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + uid, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(uid).To(Equal(victimID)) + }) + + It("rejects garbage", func() { + _, err := verifyLinkToken("not-a-jwt") + Expect(err).To(HaveOccurred()) + }) + + It("rejects a token whose scope claim is wrong", func() { + wrongScopeToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": "some-other-scope", + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(wrongScopeToken) + Expect(err).To(MatchError("invalid link token scope")) + }) + + It("rejects a scoped token that has no expiration", func() { + nonExpiringToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(nonExpiringToken) + Expect(err).To(MatchError("link token missing expiration")) + }) + }) +}) diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go new file mode 100644 index 000000000..fd8ceb3c9 --- /dev/null +++ b/adapters/lastfm/link_token.go @@ -0,0 +1,50 @@ +package lastfm + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/core/auth" +) + +const ( + linkTokenScope = "lastfm-link" + linkTokenTTL = 5 * time.Minute +) + +// createLinkToken issues a signed token binding the Last.fm callback to the +// user who initiated the OAuth flow. It travels back through Last.fm via the +// `cb` URL in place of the previously-trusted raw `uid` query parameter. +func createLinkToken(userID string) (string, error) { + claims := map[string]any{ + "uid": userID, + "scope": linkTokenScope, + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + } + return auth.EncodeToken(claims) +} + +// verifyLinkToken validates a signed link token and returns the encoded user ID. +// It enforces both the signature/expiry (via the underlying JWT verifier) and a +// dedicated scope claim, preventing tokens minted for other purposes (e.g. a +// regular session JWT) from being accepted here. +func verifyLinkToken(tokenStr string) (string, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return "", err + } + // jwtauth treats a token without `exp` as non-expiring; require it + // explicitly so an accidental regression cannot mint permanent tokens. + if exp, ok := token.Expiration(); !ok || exp.IsZero() { + return "", errors.New("link token missing expiration") + } + var scope string + if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope { + return "", errors.New("invalid link token scope") + } + var uid string + if err := token.Get("uid", &uid); err != nil || uid == "" { + return "", errors.New("invalid link token user ID") + } + return uid, nil +} diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 826a9672e..76beed921 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -141,24 +141,37 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam res := make([]agents.Song, len(resp)) for i, t := range resp { - mbid := "" - if len(t.ArtistMBIDs) > 0 { - mbid = t.ArtistMBIDs[0] - } - res[i] = agents.Song{ - Album: t.ReleaseName, - AlbumMBID: t.ReleaseMBID, - Artist: t.ArtistName, - ArtistMBID: mbid, - Duration: t.DurationMs, - Name: t.RecordingName, - MBID: t.RecordingMbid, + Album: t.ReleaseName, + AlbumMBID: t.ReleaseMBID, + Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs), + Duration: t.DurationMs, + Name: t.RecordingName, + MBID: t.RecordingMbid, } } return res, nil } +// topSongArtists maps the top-recordings response, which carries a single combined display name +// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not +// positionally pairable, so the display name attaches to the first credit and any further MBIDs +// become MBID-only collaborators — still valid identity signals for the matcher. +func topSongArtists(name string, mbids []string) []agents.Artist { + if len(mbids) == 0 { + if name == "" { + return nil + } + return []agents.Artist{{Name: name}} + } + artists := make([]agents.Artist, len(mbids)) + artists[0] = agents.Artist{Name: name, MBID: mbids[0]} + for i, m := range mbids[1:] { + artists[i+1] = agents.Artist{MBID: m} + } + return artists +} + func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { if mbid == "" { return nil, agents.ErrNotFound @@ -203,7 +216,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin songs[i] = agents.Song{ Album: song.ReleaseName, AlbumMBID: song.ReleaseMBID, - Artist: song.Artist, + Artists: []agents.Artist{{Name: song.Artist}}, MBID: song.MBID, Name: song.Name, } diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index df70ec9c4..2c4668296 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -249,24 +249,22 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, { - ID: "", - Name: "String Theocracy", - MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "String Theocracy", - AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", - Duration: 174000, + ID: "", + Name: "String Theocracy", + MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "String Theocracy", + AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + Duration: 174000, }, })) }) @@ -278,17 +276,45 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artist: "Mili", - ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, })) }) + + It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() { + body := `[{ + "recording_name": "Collab", + "recording_mbid": "rec-1", + "artist_name": "Drake feat. Future", + "artist_mbids": ["mbid-drake", "mbid-future"], + "release_name": "Album", + "release_mbid": "rel-1", + "length": 200000 + }]` + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(HaveLen(1)) + Expect(data[0].Artists).To(Equal([]agents.Artist{ + {Name: "Drake feat. Future", MBID: "mbid-drake"}, + {MBID: "mbid-future"}, + })) + }) + + It("leaves Artists nil when the top song carries no name or MBIDs", func() { + body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]` + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(HaveLen(1)) + Expect(data[0].Artists).To(BeNil()) + }) }) Describe("GetSimilarArtists", func() { @@ -393,26 +419,24 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artist: "a‐ha", - ArtistMBID: "", - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artists: []agents.Artist{{Name: "a‐ha"}}, + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, { - ID: "", - Name: "Wake Me Up Before You Go‐Go", - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - ISRC: "", - Artist: "Wham!", - ArtistMBID: "", - Album: "Make It Big", - AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", - Duration: 0, + ID: "", + Name: "Wake Me Up Before You Go‐Go", + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + ISRC: "", + Artists: []agents.Artist{{Name: "Wham!"}}, + Album: "Make It Big", + AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", + Duration: 0, }, })) }) @@ -427,15 +451,14 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artist: "a‐ha", - ArtistMBID: "", - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artists: []agents.Artist{{Name: "a‐ha"}}, + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, })) }) diff --git a/cmd/backup.go b/cmd/backup.go index ab73f7537..c02f3a19f 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -75,7 +75,7 @@ var ( func runBackup(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } idx := strings.LastIndex(conf.Server.DbPath, "?") @@ -104,7 +104,7 @@ func runBackup(ctx context.Context) { func runPrune(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } if backupCount != -1 { diff --git a/cmd/inspect.go b/cmd/inspect.go index 9f9270b1e..5e88793cc 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{ }, } -var marshalers = map[string]func(interface{}) ([]byte, error){ +var marshalers = map[string]func(any) ([]byte, error){ "pretty": prettyMarshal, "toml": toml.Marshal, "yaml": yaml.Marshal, "json": json.Marshal, - "jsonindent": func(v interface{}) ([]byte, error) { + "jsonindent": func(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }, } -func prettyMarshal(v interface{}) ([]byte, error) { +func prettyMarshal(v any) ([]byte, error) { out := v.([]core.InspectOutput) var res strings.Builder for i := range out { diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 000000000..6cce8ea5f --- /dev/null +++ b/cmd/plugin.go @@ -0,0 +1,558 @@ +package cmd + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/spf13/cobra" +) + +// pluginManager is the subset of *plugins.Manager the CLI needs. +type pluginManager interface { + EnablePlugin(ctx context.Context, id string) error + DisablePlugin(ctx context.Context, id string) error + ValidatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginConfig(ctx context.Context, id, configJSON string) error + UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error + UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error + RescanPlugins(ctx context.Context) error +} + +var ( + pluginListFormat string + pluginInfoFormat string +) + +var ( + editConfig string + editConfigFile string + editUsers string + editAllUsers bool + editLibraries string + editAllLibs bool + editWriteAccess bool + editNoWrite bool +) + +func init() { + rootCmd.AddCommand(pluginRoot) + + pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]") + pluginRoot.AddCommand(pluginListCmd) + pluginRoot.AddCommand(pluginEnableCmd) + pluginRoot.AddCommand(pluginDisableCmd) + + pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON") + pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)") + pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file") + pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`) + pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users") + pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users") + pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`) + pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries") + pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries") + pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries") + pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries") + pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access") + pluginRoot.AddCommand(pluginEditCmd) + + pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]") + pluginRoot.AddCommand(pluginInfoCmd) + pluginRoot.AddCommand(pluginValidateCmd) + pluginRoot.AddCommand(pluginRescanCmd) +} + +var ( + pluginRoot = &cobra.Command{ + Use: "plugin", + Short: "Manage and inspect plugins", + Long: "List, inspect, enable, disable, configure, rescan, and validate plugins", + } + + pluginListCmd = &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Run: func(cmd *cobra.Command, args []string) { + runPluginList(cmd.Context()) + }, + } + + pluginEnableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a plugin", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := enablePlugin(ctx, mgr, args[0]); err != nil { + log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err) + } + }, + } + + pluginDisableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a plugin", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := disablePlugin(ctx, mgr, args[0]); err != nil { + log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err) + } + }, + } +) + +var ( + pluginInfoCmd = &cobra.Command{ + Use: "info ", + Short: "Show details for an installed plugin or a .ndp package", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runPluginInfo(cmd.Context(), args[0]) + }, + } + + pluginValidateCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate an installed plugin or a .ndp package manifest", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runPluginValidate(cmd.Context(), args[0]) + }, + } +) + +// isPackagePath checks only the extension (not existence) so a mistyped path +// still routes to ReadManifest, which reports a precise "no such file" error. +func isPackagePath(arg string) bool { + return strings.HasSuffix(arg, plugins.PackageExtension) +} + +func formatPluginInfo(p *model.Plugin, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "text": + default: + return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) + } + name, version := manifestSummary(*p) + var sb strings.Builder + fmt.Fprintf(&sb, "ID: %s\n", p.ID) + fmt.Fprintf(&sb, "Name: %s\n", name) + fmt.Fprintf(&sb, "Version: %s\n", version) + fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled) + fmt.Fprintf(&sb, "Path: %s\n", p.Path) + fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256) + fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers) + fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries) + fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess) + if p.Users != "" { + fmt.Fprintf(&sb, "Users: %s\n", p.Users) + } + if p.Libraries != "" { + fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries) + } + if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil { + if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { + fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) + } + } + if !p.CreatedAt.IsZero() { + fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339)) + } + if !p.UpdatedAt.IsZero() { + fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339)) + } + if p.Config != "" { + fmt.Fprintf(&sb, "Config: %s\n", p.Config) + } + if p.LastError != "" { + fmt.Fprintf(&sb, "Last error: %s\n", p.LastError) + } + return sb.String(), nil +} + +func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(struct { + *plugins.Manifest + SHA256 string `json:"sha256"` + }{m, sha256}, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "text": + default: + return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) + } + var sb strings.Builder + fmt.Fprintf(&sb, "Name: %s\n", m.Name) + fmt.Fprintf(&sb, "Version: %s\n", m.Version) + fmt.Fprintf(&sb, "Author: %s\n", m.Author) + if m.Description != nil { + fmt.Fprintf(&sb, "Description: %s\n", *m.Description) + } + if m.Website != nil { + fmt.Fprintf(&sb, "Website: %s\n", *m.Website) + } + if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { + fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) + } + fmt.Fprintf(&sb, "SHA256: %s\n", sha256) + return sb.String(), nil +} + +func runPluginInfo(ctx context.Context, arg string) { + if isPackagePath(arg) { + m, err := plugins.ReadManifest(arg) + if err != nil { + log.Fatal(ctx, "Failed to read package", "path", arg, err) + } + sha, err := plugins.ComputeFileSHA256(arg) + if err != nil { + log.Fatal(ctx, "Failed to hash package", "path", arg, err) + } + out, err := formatManifestInfo(m, sha, pluginInfoFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) + return + } + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + p, err := ds.Plugin(ctx).Get(arg) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", arg, err) + } + out, err := formatPluginInfo(p, pluginInfoFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) +} + +func runPluginValidate(ctx context.Context, arg string) { + if isPackagePath(arg) { + if _, err := plugins.ReadManifest(arg); err != nil { + log.Fatal(ctx, "Validation failed", "path", arg, err) + } + fmt.Printf("%s: OK\n", arg) + return + } + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + p, err := ds.Plugin(ctx).Get(arg) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", arg, err) + } + if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil { + log.Fatal(ctx, "Validation failed", "id", arg, err) + } + if p.Config != "" { + mgr := GetPluginManager(ctx) + if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil { + log.Fatal(ctx, "Config validation failed", "id", arg, err) + } + } + fmt.Printf("%s: OK\n", arg) +} + +// manifestSummary extracts the display name and version from a stored manifest JSON, falling +// back to the plugin ID when the manifest can't be parsed. +func manifestSummary(p model.Plugin) (name, version string) { + var m struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil { + return p.ID, "" + } + return m.Name, m.Version +} + +func formatPluginList(list model.Plugins, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return "", err + } + return string(b), nil + case "csv": + var sb strings.Builder + w := csv.NewWriter(&sb) + _ = w.Write([]string{"id", "name", "version", "enabled", "last error"}) + for _, p := range list { + name, version := manifestSummary(p) + _ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError}) + } + w.Flush() + return sb.String(), w.Error() + case "table": + var sb strings.Builder + w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR") + for _, p := range list { + name, version := manifestSummary(p) + fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError) + } + w.Flush() + return sb.String(), nil + default: + return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format) + } +} + +func runPluginList(ctx context.Context) { + requirePluginsEnabled(ctx) + ds, ctx := getAdminContext(ctx) + list, err := ds.Plugin(ctx).GetAll() + if err != nil { + log.Fatal(ctx, "Failed to list plugins", err) + } + out, err := formatPluginList(list, pluginListFormat) + if err != nil { + log.Fatal(ctx, "Failed to format output", err) + } + fmt.Print(out) +} + +// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp +// inspection deliberately skips this so it works without a configured server. +func requirePluginsEnabled(ctx context.Context) { + if !conf.Server.Plugins.Enabled { + log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)") + } +} + +func enablePlugin(ctx context.Context, mgr pluginManager, id string) error { + return mgr.EnablePlugin(ctx, id) +} + +func disablePlugin(ctx context.Context, mgr pluginManager, id string) error { + return mgr.DisablePlugin(ctx, id) +} + +type pluginEditOptions struct { + config *string // nil = leave unchanged + users *string + allUsers *bool + libraries *string + allLibraries *bool + writeAccess *bool +} + +var pluginEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Update a plugin's config and/or permissions", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + ds, ctx := getAdminContext(cmd.Context()) + cur, err := ds.Plugin(ctx).Get(args[0]) + if err != nil { + log.Fatal(ctx, "Plugin not found", "id", args[0], err) + } + mgr := GetPluginManager(ctx) + opts := buildEditOptionsFromFlags(ctx, cmd) + if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil { + log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err) + } + }, +} + +func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions { + var opts pluginEditOptions + switch { + case cmd.Flags().Changed("config"): + c := editConfig + opts.config = &c + case cmd.Flags().Changed("config-file"): + c := readConfigFile(ctx, editConfigFile) + opts.config = &c + } + if cmd.Flags().Changed("users") { + u := editUsers + opts.users = &u + } + if cmd.Flags().Changed("all-users") { + v := editAllUsers + opts.allUsers = &v + } + if cmd.Flags().Changed("libraries") { + l := editLibraries + opts.libraries = &l + } + if cmd.Flags().Changed("all-libraries") { + v := editAllLibs + opts.allLibraries = &v + } + if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") { + // write-access is part of the library-permission group, so it is updated + // alongside the (preserved) library list rather than on its own. + wa := editWriteAccess && !editNoWrite + opts.writeAccess = &wa + } + return opts +} + +func readConfigFile(ctx context.Context, path string) string { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + log.Fatal(ctx, "Failed to read config file", "path", path, err) + } + return string(data) +} + +// applyPluginEdit applies the requested changes on top of the plugin's current +// state. Like the native API, it reads the existing users/libraries before +// updating so that flipping one flag (e.g. --write-access) does not wipe +// unspecified fields, and rejects non-JSON users/libraries values. +func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error { + if opts.config == nil && opts.users == nil && opts.allUsers == nil && + opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil { + return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access") + } + id := cur.ID + if opts.config != nil { + if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil { + return err + } + } + if opts.users != nil || opts.allUsers != nil { + users, allUsers := cur.Users, cur.AllUsers + if opts.users != nil { + parsed, err := usersToJSON(*opts.users) + if err != nil { + return err + } + users = parsed + allUsers = false // an explicit list means "restrict to these users" + } + if opts.allUsers != nil { + allUsers = *opts.allUsers + } + if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil { + return err + } + } + if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil { + libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess + if opts.libraries != nil { + parsed, err := librariesToJSON(*opts.libraries) + if err != nil { + return err + } + libs = parsed + allLibs = false // an explicit list means "restrict to these libraries" + } + if opts.allLibraries != nil { + allLibs = *opts.allLibraries + } + if opts.writeAccess != nil { + writeAccess = *opts.writeAccess + } + if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil { + return err + } + } + return nil +} + +// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated +// list and returns the JSON-array form the manager stores. +func usersToJSON(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "[]", nil + } + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if !json.Valid([]byte(value)) { + return "", fmt.Errorf("invalid JSON in --users") + } + return value, nil + } + var names []string + for _, u := range strings.Split(value, ",") { + if u = strings.TrimSpace(u); u != "" { + names = append(names, u) + } + } + b, _ := json.Marshal(names) + return string(b), nil +} + +// librariesToJSON accepts either a JSON array (starts with '[') or a +// comma-separated list of integer IDs and returns the JSON-array form stored. +func librariesToJSON(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "[]", nil + } + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if !json.Valid([]byte(value)) { + return "", fmt.Errorf("invalid JSON in --libraries") + } + return value, nil + } + ids := []int{} + for _, l := range strings.Split(value, ",") { + if l = strings.TrimSpace(l); l != "" { + id, err := strconv.Atoi(l) + if err != nil { + return "", fmt.Errorf("invalid library ID %q: must be an integer", l) + } + ids = append(ids, id) + } + } + b, _ := json.Marshal(ids) + return string(b), nil +} + +var pluginRescanCmd = &cobra.Command{ + Use: "rescan", + Short: "Re-discover plugins in the plugins folder", + Run: func(cmd *cobra.Command, args []string) { + requirePluginsEnabled(cmd.Context()) + _, ctx := getAdminContext(cmd.Context()) + mgr := GetPluginManager(ctx) + if err := rescanPlugins(ctx, mgr); err != nil { + log.Fatal(ctx, "Failed to rescan plugins", err) + } + }, +} + +func rescanPlugins(ctx context.Context, mgr pluginManager) error { + return mgr.RescanPlugins(ctx) +} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go new file mode 100644 index 000000000..b6233057f --- /dev/null +++ b/cmd/plugin_test.go @@ -0,0 +1,360 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var samplePlugins = model.Plugins{ + {ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}, + {ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"}, +} + +var _ = Describe("plugin command format flags", func() { + // Regression: list and info must not share a format variable. Binding the + // same var on both commands makes the last init() registration clobber the + // other's default, breaking `plugin list` with no -f flag. + It("defaults `list -f` to table", func() { + Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table")) + }) + + It("defaults `info -f` to text", func() { + Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text")) + }) +}) + +var _ = Describe("formatPluginList", func() { + It("renders csv with a header and one row per plugin", func() { + out, err := formatPluginList(samplePlugins, "csv") + Expect(err).ToNot(HaveOccurred()) + lines := strings.Split(strings.TrimSpace(out), "\n") + Expect(lines).To(HaveLen(3)) // header + 2 rows + Expect(lines[0]).To(ContainSubstring("id")) + Expect(out).To(ContainSubstring("alpha")) + Expect(out).To(ContainSubstring("beta")) + }) + + It("renders valid json", func() { + out, err := formatPluginList(samplePlugins, "json") + Expect(err).ToNot(HaveOccurred()) + var got []map[string]any + Expect(json.Unmarshal([]byte(out), &got)).To(Succeed()) + Expect(got).To(HaveLen(2)) + }) + + It("renders a human table by default", func() { + out, err := formatPluginList(samplePlugins, "table") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("Alpha")) + Expect(out).To(ContainSubstring("1.0.0")) + }) + + It("errors on an unknown format", func() { + _, err := formatPluginList(samplePlugins, "yaml") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("enable/disable plugin", func() { + It("calls EnablePlugin on the manager", func() { + mgr := &tests.MockPluginManager{} + err := enablePlugin(context.Background(), mgr, "alpha") + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"})) + }) + + It("calls DisablePlugin on the manager", func() { + mgr := &tests.MockPluginManager{} + err := disablePlugin(context.Background(), mgr, "beta") + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"})) + }) +}) + +var _ = Describe("applyPluginEdit", func() { + var cur *model.Plugin + BeforeEach(func() { + cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true} + }) + + It("validates then updates config when config is provided", func() { + mgr := &tests.MockPluginManager{} + cfg := `{"key":"val"}` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) + Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) + }) + + It("updates users with allUsers flag", func() { + mgr := &tests.MockPluginManager{} + all := true + err := applyPluginEdit(context.Background(), mgr, cur, + pluginEditOptions{allUsers: &all}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) + }) + + It("updates libraries with allLibraries and write access", func() { + mgr := &tests.MockPluginManager{} + all := true + wr := true + err := applyPluginEdit(context.Background(), mgr, cur, + pluginEditOptions{allLibraries: &all, writeAccess: &wr}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue()) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue()) + }) + + It("preserves existing fields when only the write-access flag changes", func() { + mgr := &tests.MockPluginManager{} + no := false + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped + Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse()) + }) + + It("preserves existing users when only the all-users flag changes", func() { + mgr := &tests.MockPluginManager{} + all := true + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped + }) + + It("parses a comma-separated users value into a JSON array", func() { + mgr := &tests.MockPluginManager{} + users := "alice, bob" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) + }) + + It("passes a JSON-array users value through unchanged", func() { + mgr := &tests.MockPluginManager{} + users := `["alice","bob"]` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) + }) + + It("rejects a malformed JSON-array users value", func() { + mgr := &tests.MockPluginManager{} + users := `["alice"` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).To(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty()) + }) + + It("parses a comma-separated libraries value into a JSON array of ints", func() { + mgr := &tests.MockPluginManager{} + libs := "1, 2" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) + }) + + It("rejects a non-integer library ID", func() { + mgr := &tests.MockPluginManager{} + libs := "1,abc" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).To(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty()) + }) + + It("clears allUsers when an explicit users list is set", func() { + mgr := &tests.MockPluginManager{} + cur.AllUsers = true + users := "alice" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`)) + Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse()) + }) + + It("clears allLibraries when an explicit libraries list is set", func() { + mgr := &tests.MockPluginManager{} + cur.AllLibraries = true + libs := "1" + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`)) + Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse()) + }) + + It("does nothing and errors when no fields are set", func() { + mgr := &tests.MockPluginManager{} + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{}) + Expect(err).To(HaveOccurred()) + }) + + It("aborts the config update when validation fails", func() { + mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")} + cfg := `{"key":"val"}` + err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) + Expect(err).To(HaveOccurred()) + Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) + Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty()) + }) +}) + +var _ = Describe("isPackagePath", func() { + It("is true for any .ndp path", func() { + Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue()) + }) + It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() { + Expect(isPackagePath("/nope/x.ndp")).To(BeTrue()) + }) + It("is false for a bare plugin id", func() { + Expect(isPackagePath("my-plugin")).To(BeFalse()) + }) +}) + +var _ = Describe("formatPluginInfo", func() { + It("renders installed plugin details as text", func() { + p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true} + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alpha")) + Expect(out).To(ContainSubstring("Alpha")) + }) + It("renders json", func() { + p := &model.Plugin{ID: "alpha", Manifest: `{}`} + out, err := formatPluginInfo(p, "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alpha")) + }) +}) + +var _ = Describe("formatManifestInfo", func() { + It("renders text with name, version, author", func() { + m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("My Plugin")) + Expect(out).To(ContainSubstring("2.0.0")) + Expect(out).To(ContainSubstring("me")) + }) + + It("omits Description and Website when nil", func() { + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Description:")) + Expect(out).ToNot(ContainSubstring("Website:")) + }) + + It("includes Description and Website when set", func() { + desc := "a cool plugin" + site := "https://example.com" + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("a cool plugin")) + Expect(out).To(ContainSubstring("https://example.com")) + }) + + It("renders valid json", func() { + m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("\"name\"")) + }) +}) + +var _ = Describe("formatPluginInfo enriched text", func() { + var fixedTime time.Time + + BeforeEach(func() { + fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + }) + + It("includes Users, Libraries, Permissions, Created, Updated in text output", func() { + p := &model.Plugin{ + ID: "myplugin", + Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`, + Enabled: true, + Users: "alice,bob", + Libraries: "1,2", + CreatedAt: fixedTime, + UpdatedAt: fixedTime.Add(time.Hour), + } + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("alice,bob")) + Expect(out).To(ContainSubstring("1,2")) + Expect(out).To(ContainSubstring("subsonicapi")) + Expect(out).To(ContainSubstring("users")) + Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z")) + }) + + It("omits Users and Libraries lines when empty", func() { + p := &model.Plugin{ + ID: "myplugin", + Manifest: `{"name":"X","version":"1.0.0","author":"me"}`, + CreatedAt: fixedTime, + UpdatedAt: fixedTime, + } + out, err := formatPluginInfo(p, "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Users:")) + Expect(out).ToNot(ContainSubstring("Libraries:")) + }) + + It("does not alter json output", func() { + p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"} + out, err := formatPluginInfo(p, "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring(`"x"`)) + }) +}) + +var _ = Describe("formatManifestInfo enriched text", func() { + It("includes Permissions when declared", func() { + p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring("http")) + Expect(out).To(ContainSubstring("Permissions:")) + }) + + It("omits Permissions line when no permissions declared", func() { + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"} + out, err := formatManifestInfo(m, "abc123", "text") + Expect(err).ToNot(HaveOccurred()) + Expect(out).ToNot(ContainSubstring("Permissions:")) + }) + + It("does not alter json output", func() { + p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} + m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} + out, err := formatManifestInfo(m, "abc123", "json") + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ContainSubstring(`"name"`)) + }) +}) + +var _ = Describe("rescanPlugins", func() { + It("calls RescanPlugins on the manager", func() { + mgr := &tests.MockPluginManager{} + err := rescanPlugins(context.Background(), mgr) + Expect(err).ToNot(HaveOccurred()) + Expect(mgr.RescanPluginsCalls).To(Equal(1)) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index 08773176a..b231aae0d 100644 --- a/cmd/root.go +++ b/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 { @@ -275,16 +275,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error { } } -func scheduleDBOptimizer(ctx context.Context) func() error { +func scheduleDBAnalyzer(ctx context.Context) func() error { return func() error { - log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) + if !conf.Server.EnableScheduledDBAnalyze { + log.Info(ctx, "Scheduled DB analysis is DISABLED") + return nil + } + log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { - if scanner.IsScanning() { - log.Debug(ctx, "Skipping DB optimization because a scan is in progress") + _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { + release, ok := scanner.LockForMaintenance() + if !ok { + log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") return } - db.Optimize(ctx) + defer release() + if _, err := db.OptimizeIfNeeded(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } }) return err } diff --git a/cmd/scan.go b/cmd/scan.go index d8a563396..320b401d4 100644 --- a/cmd/scan.go +++ b/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) + } } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index beeecca19..309d09f98 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -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 diff --git a/cmd/svc.go b/cmd/svc.go index 89ca08056..7fec708ff 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service { options["Restart"] = "on-failure" options["SuccessExitStatus"] = "1 2 8 SIGKILL" options["UserService"] = false - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() options["SystemdScript"] = systemdScript if conf.Server.LogFile != "" { options["LogOutput"] = false } else { options["LogOutput"] = true - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() } svcConfig := &service.Config{ UserName: installUser, @@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command { println("Installing service with:") println(" working directory: " + executablePath()) println(" music folder: " + conf.Server.MusicFolder) - println(" data folder: " + conf.Server.DataFolder) + println(" data folder: " + conf.Server.DataFolder.String()) if conf.Server.LogFile != "" { println(" log file: " + conf.Server.LogFile) } else { - println(" logs folder: " + conf.Server.DataFolder) + println(" logs folder: " + conf.Server.DataFolder.String()) } if cfgFile != "" { conf.Server.ConfigFile, err = filepath.Abs(cfgFile) @@ -232,22 +232,21 @@ func buildExecuteCmd() *cobra.Command { } const systemdScript = `[Unit] -Description={{.Description}} -ConditionFileIsExecutable={{.Path|cmdEscape}} -{{range $i, $dep := .Dependencies}} -{{$dep}} {{end}} - +Description={{Description}} +ConditionFileIsExecutable={{Path | cmdEscape}} +{{range Dependencies}}{{.}} +{{end}} [Service] StartLimitInterval=5 StartLimitBurst=10 -ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}} -{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}} -{{if .UserName}}User={{.UserName}}{{end}} -{{if .Restart}}Restart={{.Restart}}{{end}} -{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}} +ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}} +{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}} +{{if UserName}}User={{UserName}}{{end}} +{{if Restart}}Restart={{Restart}}{{end}} +{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}} TimeoutStopSec=20 RestartSec=120 -EnvironmentFile=-/etc/sysconfig/{{.Name}} +EnvironmentFile=-/etc/sysconfig/{{Name}} Environment="ND_SYSTEMD_PRIORITY_LOGGING=1" DevicePolicy=closed @@ -260,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap -{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}} +{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}} ProtectSystem=full [Install] diff --git a/cmd/svc_test.go b/cmd/svc_test.go new file mode 100644 index 000000000..7c34563b3 --- /dev/null +++ b/cmd/svc_test.go @@ -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]) + } + } + }) +}) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0939eef4d..d6ffc44d4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - lyricsLyrics := lyrics.NewLyrics(manager) + lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go index b947e6263..cd0ac41ed 100644 --- a/conf/configtest/configtest.go +++ b/conf/configtest/configtest.go @@ -2,9 +2,7 @@ package configtest import "github.com/navidrome/navidrome/conf" +// TODO Remove this redirection and call SnapshotConfig directly from tests func SetupConfig() func() { - oldValues := *conf.Server - return func() { - conf.Server = &oldValues - } + return conf.SnapshotConfig() } diff --git a/conf/configuration.go b/conf/configuration.go index d93024c8a..e939ebb7e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,6 +2,7 @@ package conf import ( "cmp" + "encoding/json" "fmt" "net/url" "os" @@ -14,6 +15,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" + "github.com/go-viper/mapstructure/v2" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -29,8 +31,8 @@ type configOptions struct { UnixSocketPerm string EnforceNonRootUser bool MusicFolder string - DataFolder string - CacheFolder string + DataFolder Dir + CacheFolder Dir DbPath string LogLevel string LogFile string @@ -45,11 +47,11 @@ type configOptions struct { UIWelcomeMessage string MaxSidebarPlaylists int EnableTranscodingConfig bool - EnableTranscodingCancellation bool EnableDownloads bool EnableExternalServices bool EnableM3UExternalAlbumArt bool EnableInsightsCollector bool + EnableScheduledDBAnalyze bool EnableMediaFileCoverArt bool TranscodingCacheSize string ImageCacheSize string @@ -111,6 +113,7 @@ type configOptions struct { PID pidOptions `json:",omitzero"` Inspect inspectOptions `json:",omitzero"` Subsonic subsonicOptions `json:",omitzero"` + Transcoding transcodingOptions `json:",omitzero"` LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` @@ -145,22 +148,29 @@ type configOptions struct { DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 - DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool } type scannerOptions struct { - Enabled bool - Schedule string - WatcherWait time.Duration - ScanOnStartup bool - Extractor string - ArtistJoiner string - GenreSeparators string // Deprecated: Use Tags.genre.Split instead - GroupAlbumReleases bool // Deprecated: Use PID.Album instead - FollowSymlinks bool // Whether to follow symlinks when scanning directories - PurgeMissing string // Values: "never", "always", "full" + Enabled bool + Schedule string + WatcherWait time.Duration + ScanOnStartup bool + Extractor string + ArtistJoiner string + ArtistSplitExceptions []string // Artist names never split by tag separators + GenreSeparators string // Deprecated: Use Tags.genre.Split instead + GroupAlbumReleases bool // Deprecated: Use PID.Album instead + FollowSymlinks bool // Whether to follow symlinks when scanning directories + IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning + PurgeMissing string // Values: "never", "always", "full" +} + +type transcodingOptions struct { + MaxConcurrent int + MaxConcurrentPerUser int + EnableCancellation bool } type subsonicOptions struct { @@ -229,7 +239,7 @@ type jukeboxOptions struct { type backupOptions struct { Count int - Path string + Path Dir Schedule string } @@ -247,7 +257,7 @@ type inspectOptions struct { type pluginsOptions struct { Enabled bool - Folder string + Folder Dir CacheSize string AutoReload bool LogLevel string @@ -287,6 +297,22 @@ var ( hooks []func() ) +// SnapshotConfig returns a function that restores Server to its current state. +// Uses JSON round-tripping so Dir fields get fresh sync.Once values. +func SnapshotConfig() func() { + snapshot, err := json.Marshal(Server) + if err != nil { + panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err)) + } + return func() { + var restored configOptions + if err := json.Unmarshal(snapshot, &restored); err != nil { + panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err)) + } + Server = &restored + } +} + func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() @@ -306,8 +332,15 @@ func Load(noConfigDump bool) { mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") - err := viper.Unmarshal(&Server) + err := viper.Unmarshal(&Server, viper.DecodeHook( + mapstructure.ComposeDecodeHookFunc( + mapstructure.TextUnmarshallerHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + )) if err != nil { logFatal("Error parsing config:", err) } @@ -317,48 +350,28 @@ func Load(noConfigDump bool) { logFatal(err) } - err = os.MkdirAll(Server.DataFolder, os.ModePerm) - if err != nil { - logFatal("Error creating data path:", err) - } - - if Server.CacheFolder == "" { - Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") - } - err = os.MkdirAll(Server.CacheFolder, os.ModePerm) - if err != nil { - logFatal("Error creating cache path:", err) - } - - err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) - if err != nil { - logFatal("Error creating artwork path:", err) + if Server.CacheFolder.String() == "" { + Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache")) } if Server.Plugins.Enabled { - if Server.Plugins.Folder == "" { - Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") - } - err = os.MkdirAll(Server.Plugins.Folder, 0700) - if err != nil { - logFatal("Error creating plugins path:", err) + if Server.Plugins.Folder.String() == "" { + Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700) + } else { + Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700) } } Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { - Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) - } - - if Server.Backup.Path != "" { - err = os.MkdirAll(Server.Backup.Path, os.ModePerm) - if err != nil { - logFatal("Error creating backup path:", err) - } + Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath) } out := os.Stderr if Server.LogFile != "" { + if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { + logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) + } out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) @@ -445,6 +458,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation") // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") @@ -636,7 +650,7 @@ func validateScanSchedule() error { } func validateBackupSchedule() error { - if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { + if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { Server.Backup.Schedule = "" return nil } @@ -733,7 +747,6 @@ func setViperDefaults() { viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("enabletranscodingconfig", false) - viper.SetDefault("enabletranscodingcancellation", false) viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) @@ -765,7 +778,7 @@ func setViperDefaults() { viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") + viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) @@ -781,12 +794,13 @@ func setViperDefaults() { viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) - viper.SetDefault("enablesharing", false) + viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) + viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) @@ -807,9 +821,11 @@ func setViperDefaults() { viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) viper.SetDefault("scanner.scanonstartup", true) viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) + viper.SetDefault("scanner.artistsplitexceptions", []string{}) viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) + viper.SetDefault("scanner.ignoredotfolders", true) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendalbumversion", true) @@ -818,6 +834,9 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") + viper.SetDefault("transcoding.maxconcurrent", 0) + viper.SetDefault("transcoding.maxconcurrentperuser", 0) + viper.SetDefault("transcoding.enablecancellation", false) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) @@ -873,7 +892,6 @@ func setViperDefaults() { viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) - viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 5d4e73fad..e43c91a4b 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -58,6 +58,19 @@ var _ = Describe("Configuration", func() { }) }) + Describe("scheduled DB analysis", func() { + It("is enabled by default", func() { + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) + }) + + It("can be disabled", func() { + viper.Set("enablescheduleddbanalyze", false) + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) + }) + }) + Describe("ValidateURL", func() { It("accepts a valid http URL", func() { fn := conf.ValidateURL("TestOption", "http://example.com/path") @@ -186,27 +199,12 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error reading config file"))) }) - It("is called when DataFolder is not writable", func() { - viper.SetDefault("datafolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating data path"))) - }) - - It("is called when CacheFolder is not writable", func() { - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("cachefolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating cache path"))) - }) - It("is called when LogFile path is not writable", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) Expect(func() { conf.Load(true) - }).To(PanicWith(ContainSubstring("Error opening log file"))) + }).To(PanicWith(ContainSubstring("Error creating log file directory"))) }) It("is called when BaseURL is invalid", func() { diff --git a/conf/dir.go b/conf/dir.go new file mode 100644 index 000000000..f7a14b933 --- /dev/null +++ b/conf/dir.go @@ -0,0 +1,77 @@ +package conf + +import ( + "cmp" + "fmt" + "os" +) + +// Dir wraps a directory path and creates the directory on demand. Dir is a +// plain value type — safe to copy, compare, and print via reflection-based +// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards. +// Directory creation is delegated to os.MkdirAll on every Path() call; +// MkdirAll is idempotent, so repeated calls cost one stat syscall when the +// directory already exists. +type Dir struct { + path string + perm os.FileMode +} + +// NewDir creates a new Dir with the given path and default permissions (os.ModePerm). +func NewDir(path string) Dir { + return Dir{path: path, perm: os.ModePerm} +} + +// NewDirWithPerm creates a new Dir with the given path and permissions. +// A perm of 0 is treated as "default" and resolves to os.ModePerm at +// directory-creation time; pass an explicit non-zero mode to constrain the +// permissions. +func NewDirWithPerm(path string, perm os.FileMode) Dir { + return Dir{path: path, perm: perm} +} + +// String returns the raw path without creating the directory. Satisfies fmt.Stringer. +func (d Dir) String() string { + return d.path +} + +// Path ensures the directory exists and returns its path. Safe to call +// repeatedly; an empty path is returned as-is with no error. +func (d Dir) Path() (string, error) { + if d.path == "" { + return "", nil + } + if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil { + return d.path, fmt.Errorf("creating directory %q: %w", d.path, err) + } + return d.path, nil +} + +// MustPath calls Path() and calls logFatal on error. +func (d Dir) MustPath() string { + path, err := d.Path() + if err != nil { + logFatal("creating directory:", err) + } + return path +} + +// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) +// prints the path string instead of the internal struct fields. +func (d Dir) GoString() string { + return fmt.Sprintf("%q", d.path) +} + +// MarshalText returns the raw path bytes. No side effects. +func (d Dir) MarshalText() ([]byte, error) { + return []byte(d.path), nil +} + +// UnmarshalText sets the path from bytes. No side effects. +func (d *Dir) UnmarshalText(text []byte) error { + d.path = string(text) + if d.perm == 0 { + d.perm = os.ModePerm + } + return nil +} diff --git a/conf/dir_test.go b/conf/dir_test.go new file mode 100644 index 000000000..79db379d2 --- /dev/null +++ b/conf/dir_test.go @@ -0,0 +1,164 @@ +package conf_test + +import ( + "os" + "sync" + + "github.com/kr/pretty" + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Dir", func() { + Describe("NewDir", func() { + It("creates a Dir with the given path without side effects", func() { + d := conf.NewDir("/some/path") + Expect(d.String()).To(Equal("/some/path")) + }) + }) + + Describe("String", func() { + It("returns the raw path without creating the directory", func() { + d := conf.NewDir("/nonexistent/path/that/should/not/be/created") + Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created")) + }) + }) + + Describe("Path", func() { + It("creates the directory and returns the path on first call", func() { + dir := GinkgoT().TempDir() + target := dir + "/subdir/nested" + d := conf.NewDir(target) + + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("is idempotent on subsequent calls", func() { + dir := GinkgoT().TempDir() + target := dir + "/idempotent" + d := conf.NewDir(target) + + path1, err1 := d.Path() + path2, err2 := d.Path() + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(path1).To(Equal(path2)) + Expect(target).To(BeADirectory()) + }) + + It("returns an error when directory cannot be created", func() { + f := GinkgoT().TempDir() + blocker := f + "/blocker" + By("creating a file that blocks directory creation") + Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) + invalid := blocker + "/subdir" + + d := conf.NewDir(invalid) + _, pathErr := d.Path() + Expect(pathErr).To(HaveOccurred()) + }) + + It("returns empty path and no error for empty path", func() { + d := conf.NewDir("") + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(BeEmpty()) + }) + }) + + Describe("MustPath", func() { + It("returns the path when directory is created successfully", func() { + dir := GinkgoT().TempDir() + target := dir + "/mustpath" + d := conf.NewDir(target) + + path := d.MustPath() + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("calls logFatal on error", func() { + var fatalMsg []any + restore := conf.SetLogFatal(func(args ...any) { + fatalMsg = args + panic("logFatal called") + }) + DeferCleanup(restore) + + f := GinkgoT().TempDir() + "/blocker" + Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed()) + invalid := f + "/subdir" + + d := conf.NewDir(invalid) + Expect(func() { d.MustPath() }).To(Panic()) + Expect(fatalMsg).ToNot(BeEmpty()) + }) + }) + + Describe("MarshalText", func() { + It("returns the raw path bytes without side effects", func() { + d := conf.NewDir("/marshal/path") + b, err := d.MarshalText() + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(Equal("/marshal/path")) + }) + }) + + Describe("UnmarshalText", func() { + It("sets the path from bytes without side effects", func() { + d := conf.NewDir("") + err := d.UnmarshalText([]byte("/unmarshal/path")) + Expect(err).ToNot(HaveOccurred()) + Expect(d.String()).To(Equal("/unmarshal/path")) + }) + + It("allows round-trip marshal/unmarshal", func() { + d1 := conf.NewDir("/round/trip") + b, err := d1.MarshalText() + Expect(err).ToNot(HaveOccurred()) + + var d2 conf.Dir + err = d2.UnmarshalText(b) + Expect(err).ToNot(HaveOccurred()) + Expect(d2.String()).To(Equal(d1.String())) + }) + }) + + Describe("GoString", func() { + // Regression: pretty.Sprintf("%# v", ...) is used by the + // configuration dump. It must render Dir as a quoted path via + // GoString, not dump the internal struct fields. + It("renders Dir as a quoted path under pretty.Sprintf", func() { + type host struct { + DataFolder conf.Dir + } + h := host{DataFolder: conf.NewDir("./data")} + out := pretty.Sprintf("%# v", h) + Expect(out).To(ContainSubstring(`DataFolder: "./data"`)) + Expect(out).ToNot(ContainSubstring("perm:")) + Expect(out).ToNot(ContainSubstring("path:")) + }) + + It("is safe to copy and use concurrently", func() { + // Regression for the Windows "sync: unlock of unlocked mutex" + // crash that was caused by copying a Dir embedding sync.Once. + // Dir is a plain value type now, but keep the concurrent stress + // test to lock in the property. + dir := GinkgoT().TempDir() + d := conf.NewDir(dir + "/race") + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + copy1 := d + _ = pretty.Sprintf("%# v", copy1) + _, _ = copy1.Path() + }) + } + wg.Wait() + }) + }) +}) diff --git a/consts/consts.go b/consts/consts.go index bf32006d6..73f89b450 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -14,9 +14,16 @@ const ( DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" InitialSetupFlagKey = "InitialSetup" FullScanAfterMigrationFlagKey = "FullScanAfterMigration" + // PlaylistsImportPendingFlagKey marks that playlist import was deferred because + // no admin user existed yet; the next scan with an admin imports them. + PlaylistsImportPendingFlagKey = "PlaylistsImportPending" LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" + LastDBAnalyzeAtKey = "LastDBAnalyzeAt" + LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" + DBAnalyzePendingKey = "DBAnalyzePending" + DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -25,7 +32,8 @@ const ( DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - OptimizeDBSchedule = "@every 24h" + DBAnalyzeCheckSchedule = "@every 30m" + DBAnalyzeMaxAge = 24 * time.Hour // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option @@ -153,25 +161,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 19df91d02..d5f4a6580 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" ) @@ -33,15 +34,22 @@ type ExternalImage struct { } type Song struct { - ID string - Name string - MBID string - ISRC string - Artist string - ArtistMBID string - Album string - AlbumMBID string - Duration uint32 // Duration in milliseconds, 0 means unknown + ID string + Name string + MBID string + ISRC string + Artists []Artist + Album string + AlbumMBID string + Duration uint32 // Duration in milliseconds, 0 means unknown +} + +// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes +// rather than comparing with ==, which the Artists slice makes illegal. +func (s Song) Equals(other Song) bool { + h1, _ := hashstructure.Hash(s, nil) + h2, _ := hashstructure.Hash(other, nil) + return h1 == h2 } var ( diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go new file mode 100644 index 000000000..c13710a38 --- /dev/null +++ b/core/agents/interfaces_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) diff --git a/core/archiver.go b/core/archiver.go index 96cc2c31e..5d1c090cd 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -3,6 +3,7 @@ package core import ( "archive/zip" "context" + "errors" "fmt" "io" "os" @@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { file := a.albumFilename(mf, format, isMultiDisc) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Stop iterating: continuing would just rack up more + // rejections from the limiter. Close finalises whatever + // tracks were already written; the rejected one is not + // present in the archive (addFileToZip aborts before + // writing its entry header). + _ = z.Close() + return addErr + } } } err = z.Close() @@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { file := a.playlistFilename(mf, format, idx) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Abort the whole archive: continuing would silently emit + // empty zip entries since the headers are already written. + _ = z.Close() + return addErr + } mf.Path = file zippedMfs[idx] = mf } @@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { path := mf.AbsolutePath() + + // Open the source before writing the zip entry header so a rejection + // (limiter, missing file, etc.) does not leave an empty entry in the + // archive. + var r io.ReadCloser + var err error + if format != "raw" && format != "" { + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + } else { + r, err = os.Open(path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + return err + } + defer func() { + if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) + } + }() + w, err := z.CreateHeader(&zip.FileHeader{ Name: filename, Modified: mf.UpdatedAt, @@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return err } - var r io.ReadCloser - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) - } else { - r, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) - return err - } - - defer func() { - if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { - log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) - } - }() - _, err = io.Copy(w, r) if err != nil { log.Error(ctx, "Error zipping file", "file", path, err) diff --git a/core/archiver_test.go b/core/archiver_test.go index 4f7aed278..f432139d8 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() { }) }) + Context("when the transcode limiter rejects a file", func() { + It("aborts the archive instead of continuing with empty entries", func() { + mfs := model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + } + + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{ + Filters: squirrel.Eq{"album_id": "1"}, + Sort: "album", + }}).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}). + Return(nil, stream.ErrTooManyTranscodes).Once() + + out := new(bytes.Buffer) + err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) + Expect(err).To(MatchError(stream.ErrTooManyTranscodes)) + // NewStream should only have been called once: the loop must bail + // out on the rejection instead of trying every remaining track. + ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1) + }) + }) + Context("ZipShare", func() { It("zips a share correctly", func() { mfs := model.MediaFiles{ diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index c27964018..bf3d435a8 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID // Configure cache conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) conf.Server.CoverArtQuality = 75 conf.Server.CoverArtPriority = "cover.*" @@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() r, _, err := aw.Get(context.Background(), artID, 300, true) diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 60990bb8b..0076506f3 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte { // generateGradientImage creates an RGBA image with a diagonal gradient pattern. func generateGradientImage(width, height int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { + for y := range height { + for x := range width { r := uint8((x * 255) / width) g := uint8((y * 255) / height) b := uint8(((x + y) * 255) / (width + height)) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index e765e1b1b..5e61684cc 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -357,6 +357,98 @@ var _ = Describe("Album artwork resolution", func() { }) }) + // Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder + // fallback can pick up images from the ARTIST folder, serving the artist + // thumbnail as album art for any album without its own image files. + When("an album has no images and the artist folder has folder.jpg", func() { + // Artist/ + // ├── folder.jpg ← artist thumbnail, must NOT become album art + // ├── Album A/ + // │ └── 01 - Track.mp3 (no images) + // └── Album B/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("does not use the artist image as album art", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), + "Artist/Album B/cover.jpg": imageFile("album-b"), + }) + scan() + + alA := albumByName("Album A") + _, err := readArtworkOrErr(alA.CoverArtID()) + Expect(err).To(HaveOccurred(), + "Album A has no images of its own, so it must fall through to the placeholder "+ + "instead of inheriting the artist folder's folder.jpg") + + alB := albumByName("Album B") + Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b"))) + }) + }) + + When("a single-disc album is spread across sibling folders under the artist folder", func() { + // Artist/ + // ├── folder.jpg ← artist thumbnail, must NOT become album art + // ├── Album A/ + // │ └── 01 - Track.mp3 (album: "Album A") + // ├── Album A bonus/ + // │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder) + // └── Album B/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("does not use the artist image as album art for the spread album", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), + "Artist/Album B/cover.jpg": imageFile("album-b"), + }) + scan() + + alA := albumByName("Album A") + Expect(alA.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two sibling folders as one spread album") + _, err := readArtworkOrErr(alA.CoverArtID()) + Expect(err).To(HaveOccurred(), + "the spread album has no images of its own, so it must fall through to the "+ + "placeholder instead of inheriting the artist folder's folder.jpg") + }) + }) + + When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() { + // Artist/ + // ├── cover.jpg ← artist image; matches cover.* (first pattern), + // │ must NOT shadow the album's own front.jpg + // ├── Album A/ + // │ ├── 01 - Track.mp3 (album: "Album A") + // │ └── front.jpg ← should win + // ├── Album A bonus/ + // │ └── 02 - Track.mp3 (album: "Album A") + // └── Album B/ + // └── 01 - Track.mp3 + It("prefers the album's own art over the artist image", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/cover.jpg": imageFile("artist-image"), + "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album A/front.jpg": imageFile("album-a-front"), + "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), + "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), + }) + scan() + + alA := albumByName("Album A") + Expect(alA.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two sibling folders as one spread album") + Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front"))) + }) + }) + When("embedded is first in CoverArtPriority but the track has no embedded art", func() { // Artist/ // └── Album/ diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 9ce0edb8b..06cc05b6f 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -2,6 +2,7 @@ package artworke2e_test import ( "context" + "fmt" "path/filepath" "testing" @@ -63,7 +64,7 @@ func setupHarness() { // Reuse the suite-level DB path so the singleton connection keeps working // across specs (see suiteDBTempDir comment). conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) conf.Server.MusicFolder = fakeLibPath conf.Server.DevExternalScanner = false conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call @@ -104,3 +105,16 @@ func firstAlbum() model.Album { Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) return albums[0] } + +func albumByName(name string) model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + for _, al := range albums { + if al.Name == name { + return al + } + } + Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) + return model.Album{} +} diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 73ba9b5ee..8ad07773b 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -113,28 +113,12 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return nil, nil, nil, err } - folderIDSet := make(map[string]bool, len(folderIDs)) - for _, id := range folderIDs { - folderIDSet[id] = true + parent, err := albumRootParent(ctx, ds, folders, folderIDs) + if err != nil { + return nil, nil, nil, err } - - // Check if all folders share a common parent that is not already included. - // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" - // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). - // For single-folder albums, the parent is only included when the folder has no - // images of its own (indicating a disc subfolder needing parent artwork). - if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { - if len(folders) >= 2 || !anyFolderHasImages(folders) { - parentFolder, err := ds.Folder(ctx).Get(commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) - } else if err != nil { - return nil, nil, nil, err - } - if parentFolder != nil && parentFolder.ParentID != "" { - folders = append(folders, *parentFolder) - } - } + if parent != nil { + folders = append(folders, *parent) } var paths []string @@ -159,6 +143,50 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +// albumRootParent returns the common parent of the album's folders when it +// qualifies as the album's root folder (e.g. "Artist/Album" above disc +// subfolders), or nil when there is no such parent. This finds cover art in +// the album root folder when tracks live in disc subfolders, like +// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and +// "Artist/Album/CD2/". The parent must look like an album root, not an +// artist-level folder — it qualifies only when it holds no audio belonging to +// other albums — so artist images are never served as album art. +func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) { + folderIDSet := make(map[string]bool, len(folderIDs)) + for _, id := range folderIDs { + folderIDSet[id] = true + } + commonParentID := commonParentFolder(folders, folderIDSet) + if commonParentID == "" { + return nil, nil + } + // Single-folder albums only use the parent when the folder has no images + // of its own (indicating a disc subfolder needing parent artwork). + if len(folders) < 2 && anyFolderHasImages(folders) { + return nil, nil + } + parent, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + return nil, nil + } + if err != nil { + return nil, err + } + if parent.ParentID == "" { + // The library root can never be an album root + return nil, nil + } + hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs) + if err != nil { + return nil, err + } + if hasOtherAudio { + return nil, nil + } + return parent, nil +} + func anyFolderHasImages(folders []model.Folder) bool { for _, f := range folders { if len(f.ImageFiles) > 0 { diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index 1cf039bee..fe4a1a545 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -339,6 +339,61 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("does not include parent images when other albums' audio lives under the parent", func() { + // Simulates: Artist/folder.jpg with Artist/Album (no images) and + // another album's tracks elsewhere under the artist folder + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ParentID: "libraryRoot", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"folder.jpg"}, + } + repo.hasOtherAudio = true + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(BeEmpty()) + }) + + It("propagates errors from the album-root check", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + repo.otherAudioErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + }) + It("propagates non-ErrNotFound errors from parent folder lookup", func() { repo.result = []model.Folder{ { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index e2a1f2094..6d6d58fc5 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) // Create the artwork/artist directory Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) @@ -702,12 +702,20 @@ type fakeFolderRepo struct { getErr error getCallCount int err error + // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root + // check). False means the parent qualifies as an album root. + hasOtherAudio bool + otherAudioErr error } func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, f.err } +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return f.hasOtherAudio, f.otherAudioErr +} + func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { f.getCallCount++ if f.getErr != nil { diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go index 1f5bc9084..37ce1d827 100644 --- a/core/artwork/reader_radio_test.go +++ b/core/artwork/reader_radio_test.go @@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 85a19a4c3..cd16cbada 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -21,6 +21,12 @@ import ( func init() { conf.AddHook(func() { + // gen2brain/webp selects native (purego/libwebp) vs WASM in its own + // package init() and exposes the result only via webp.Dynamic(); there is + // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path + // crashes (issue #5597), so those builds must be compiled with the + // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an + // error here and forces the safe WASM path. if err := webp.Dynamic(); err != nil { log.Debug("Using WASM WebP encoder/decoder", "reason", err) } else { @@ -126,6 +132,22 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return resizeStaticImage(data, a.size, a.square) } +// toFastScaleType converts images whose concrete type has no optimized scaler +// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed +// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale +// falls back to a generic per-pixel At()/RGBA() loop that is several times +// slower. Fast-path types are returned unchanged. +func toFastScaleType(img image.Image) image.Image { + switch img.(type) { + case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: + return img + default: + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba + } +} + func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { original, format, err := image.Decode(bytes.NewReader(data)) if err != nil { @@ -163,6 +185,7 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dstRect = dst.Bounds() } + original = toFastScaleType(original) xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) buf := bufPool.Get().(*bytes.Buffer) diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 761dd205c..3a3585e53 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -21,8 +21,7 @@ func TestAuth(t *testing.T) { } const ( - testJWTSecret = "not so secret" - oneDay = 24 * time.Hour + oneDay = 24 * time.Hour ) var _ = BeforeSuite(func() { diff --git a/core/external/provider.go b/core/external/provider.go index 4f3295cc7..459e8a205 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl return album, err } - album.ExternalInfoUpdatedAt = P(time.Now()) + album.ExternalInfoUpdatedAt = new(time.Now()) album.ExternalUrl = info.URL if info.Description != "" { @@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = P(time.Now()) + artist.ExternalInfoUpdatedAt = new(time.Now()) err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, @@ -471,13 +471,19 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) } - // Enrich songs with artist info if not already present (for top songs, we know the artist) + // Enrich top songs with the queried artist. A song with no artists, or whose first credit the + // agent left unnamed, is attributed to the queried artist. A first credit that already names an + // artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried + // MBID onto it would create a false name+MBID pairing. for i := range songs { - if songs[i].Artist == "" { - songs[i].Artist = artistName - } - if songs[i].ArtistMBID == "" { - songs[i].ArtistMBID = artist.MbzArtistID + switch { + case len(songs[i].Artists) == 0: + songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}} + case songs[i].Artists[0].Name == "": + songs[i].Artists[0].Name = artistName + if songs[i].Artists[0].MBID == "" { + songs[i].Artists[0].MBID = artist.MbzArtistID + } } } diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 37d3fd81a..79612d651 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns cached URL and does not call agent when info is not expired", func() { // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - recentTime := time.Now().Add(-1 * time.Minute) cachedArtist := &model.Artist{ ID: "artist-cached", Name: "Cached Artist", LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: &recentTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), } mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") @@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns stale URL and enqueues refresh when info is expired", func() { // Arrange conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - expiredTime := time.Now().Add(-1 * time.Hour) staleArtist := &model.Artist{ ID: "artist-expired", Name: "Expired Artist", LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: &expiredTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), } mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index c9a1a64ef..563003f83 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -3,6 +3,7 @@ package external_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" @@ -56,7 +57,14 @@ var _ = Describe("Provider - SimilarSongs", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} - matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"} + + // Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping. + dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"} + dmParticipant := model.Participant{Artist: dmArtist} + matchedSong := model.MediaFile{ + ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}}, + } // GetEntityByID tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() @@ -65,16 +73,19 @@ var _ = Describe("Provider - SimilarSongs", func() { agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). Return([]agents.Song{ - {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"}, + {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, }, nil).Once() - // Mock loadTracksByID - no ID matches + // Matcher artist resolution: resolve Depeche Mode in the artist table. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe() + + // ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok - })).Return(model.MediaFiles{}, nil).Once() + })).Return(model.MediaFiles{}, nil).Maybe() - // Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called) + // MBID phase: won't fire (empty MBID). mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok || len(and) < 1 { @@ -88,18 +99,19 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasMBID })).Return(model.MediaFiles{}, nil).Maybe() - // Mock loadTracksByTitleAndArtist - queries by artist name + // Matcher track-fetch: subquery returns the matched song with participants. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { + if !ok { return false } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } } - _, hasArtist := eq["order_artist_name"] - return hasArtist + return false })).Return(model.MediaFiles{matchedSong}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "track-1", 5) @@ -165,7 +177,7 @@ var _ = Describe("Provider - SimilarSongs", func() { agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5). Return([]agents.Song{ - {Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"}, + {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches @@ -242,7 +254,7 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). Return([]agents.Song{ - {Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"}, + {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 0d9b5800d..86f9110e2 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -76,6 +76,63 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) + It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) + + // Agent leaves the first credit unnamed (e.g. an MBID-less collaborator slot). That blank + // credit IS the queried artist, so enrichment fills both name and MBID; the song then matches + // the queried artist's track via the backfilled identity. + agentSongs := []agents.Song{ + {Name: "Song One", Artists: []agents.Artist{{}}}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + track := model.MediaFile{ + ID: "song-1", Title: "Song One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, + }}, + } + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) + + songs, err := p.TopSongs(ctx, "Artist One", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + + It("does not stamp the queried MBID onto an already-named different first credit", func() { + // The queried artist (One) appears only as a featured collaborator; the displayed first credit + // is a DIFFERENT artist (Two) returned without an MBID. Enrichment must NOT assign One's MBID + // to Two — only Two's name match (which fails here) or One's own credit may resolve the track. + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) + + agentSongs := []agents.Song{ + {Name: "Collab Song", Artists: []agents.Artist{{Name: "Artist Two"}}}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + // Library track is credited to Artist One (the queried artist) under a same title. If the + // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID + // could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track. + track := model.MediaFile{ + ID: "one-track", Title: "Collab Song", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, + }}, + } + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) + + songs, err := p.TopSongs(ctx, "Artist One", 1) + + Expect(err).ToNot(HaveOccurred()) + // "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track. + Expect(songs).To(BeEmpty()) + }) + It("returns nil for an unknown artist", func() { // Mock artist not found artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once() @@ -148,6 +205,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for the title-match path (song2 falls through). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response agentSongs := []agents.Song{ @@ -159,7 +218,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding matching tracks (only find song 1 on bulk query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2 + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -195,6 +254,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for both title-fallback songs. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with songs that have NO MBID (empty string) agentSongs := []agents.Song{ @@ -203,10 +264,16 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Since there are no MBIDs, loadTracksByMBID should not make any database call - // loadTracksByTitle should make a database call for title matching - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + // Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them. + participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} + song1 := model.MediaFile{ + ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } + song2 := model.MediaFile{ + ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -224,6 +291,8 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + // Matcher artist resolution for song2's title-fallback path. + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with mixed MBID availability agentSongs := []agents.Song{ @@ -236,8 +305,12 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - // Mock the title fallback query (finds song2 by title) - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + // Title track-fetch: song2 must carry RoleArtist participants for back-mapping. + participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} + song2 := model.MediaFile{ + ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, + } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 3dd8a587a..21824c93f 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://cached.com/album", Description: "Cached Desc", LargeImageUrl: "http://cached.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) @@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://expired.com/album", Description: "Expired Desc", LargeImageUrl: "http://expired.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index cc9506d1f..d783128fb 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ExternalUrl: "http://cached.url", Biography: "Cached Bio", LargeImageUrl: "http://cached_large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-similar-present", Name: "Similar Present"}, {ID: "ar-similar-absent", Name: "Similar Absent"}, @@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-expired", Name: "Expired Artist", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), SimilarArtists: model.Artists{ {ID: "ar-exp-similar", Name: "Expired Similar"}, }, @@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-similar-test", Name: "Similar Test Artist", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-sim-present", Name: "Similar Present"}, {ID: "", Name: "Similar Absent Raw"}, diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 80790c8d6..3d4cd0e72 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -325,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { errMsg += ": " + stderrOutput @@ -394,14 +394,23 @@ func isDefaultCommand(format, command string) bool { // including all transcoding parameters (bitrate, sample rate, channels). func buildDynamicArgs(opts TranscodeOptions) []string { cmdPath, _ := ffmpegCmd() - args := []string{cmdPath, "-i", opts.FilePath} + args := []string{cmdPath} if opts.Offset > 0 { args = append(args, "-ss", strconv.Itoa(opts.Offset)) } + args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") + // Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC); + // -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG). + // Both are needed because the two source families store tags at different + // levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids + // pulling metadata from an embedded cover-art/video stream at index 0. Note: + // adts (AAC) output cannot hold tags, so these are a no-op there. + args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0") + if codec, ok := formatCodecMap[opts.Format]; ok { args = append(args, "-c:a", codec) } @@ -491,11 +500,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string for _, s := range fixCmd(cmd) { if strings.Contains(s, "%s") { + if offset > 0 && !strings.Contains(cmd, "%t") { + // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) + // instead of decoding all frames up to the offset (slow). + insertAt := len(args) + for i, arg := range slices.Backward(args) { + if arg == "-i" { + insertAt = i + break + } + } + args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset)) + } s = strings.ReplaceAll(s, "%s", path) args = append(args, s) - if offset > 0 && !strings.Contains(cmd, "%t") { - args = append(args, "-ss", strconv.Itoa(offset)) - } } else { s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 1649015d9..9c20e6c05 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "runtime" "strings" - sync "sync" + "sync" "testing" "time" @@ -47,15 +47,15 @@ var _ = Describe("ffmpeg", func() { }) Context("when command has time offset param", func() { It("creates a valid command line with offset", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"})) + args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) Context("when command does not have time offset param", func() { - It("adds time offset after the input file name", func() { + It("adds time offset before the input file name", func() { args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"})) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) }) @@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() { Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -113,6 +113,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "256k", "-ar", "48000", @@ -132,6 +133,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-ar", "48000", "-v", "0", @@ -149,6 +151,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libopus", "-b:a", "128k", "-v", "0", @@ -165,9 +168,11 @@ var _ = Describe("ffmpeg", func() { Offset: 30, }) Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.mp3", + "ffmpeg", "-ss", "30", + "-i", "/music/file.mp3", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "192k", "-v", "0", @@ -185,6 +190,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "aac", "-b:a", "256k", "-v", "0", @@ -202,6 +208,7 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", + "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-sample_fmt", "s32", "-v", "0", diff --git a/core/image_upload_test.go b/core/image_upload_test.go index d13a04775..265f60a95 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) svc = core.NewImageUploadService() }) diff --git a/core/inspect.go b/core/inspect.go index 751cf063f..01ec33760 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - . "github.com/navidrome/navidrome/utils/gg" ) type InspectOutput struct { @@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: P(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(libraryId, folderId)), } return result, nil diff --git a/core/library.go b/core/library.go index 0bf3be9fa..365dcbd4c 100644 --- a/core/library.go +++ b/core/library.go @@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error { return r.mapError(err) } - err = r.LibraryRepository.Delete(libID) + // Run the deletion in a transaction so the cascade delete and the orphaned-artist + // reconciliation it triggers (see libraryRepository.Delete) commit atomically. + err = r.ds.WithTx(func(tx model.DataStore) error { + return tx.Library(r.ctx).Delete(libID) + }, "delete library") if err != nil { return r.mapError(err) } diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 758053042..b9fb8cb74 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -4,56 +4,122 @@ import ( "context" "strings" + . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" ) -// Lyrics can fetch lyrics for a media file. -type Lyrics interface { +// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy +// artist/title lookup, so source-priority resolution can still reach older +// matches without turning it into an unbounded table scan. +const maxLegacyLyricsCandidates = 10 + +// Provider fetches lyrics for a single media file. It is the contract +// implemented by individual lyrics sources, such as plugins. +type Provider interface { GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) } +// Lyrics resolves lyrics for media files, honoring the configured source +// priority. +type Lyrics interface { + Provider + GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) +} + // PluginLoader discovers and loads lyrics provider plugins. type PluginLoader interface { - LoadLyricsProvider(name string) (Lyrics, bool) + LoadLyricsProvider(name string) (Provider, bool) } type lyricsService struct { + ds model.DataStore pluginLoader PluginLoader } // NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin // system is available. -func NewLyrics(pluginLoader PluginLoader) Lyrics { - return &lyricsService{pluginLoader: pluginLoader} +func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics { + return &lyricsService{ds: ds, pluginLoader: pluginLoader} } // GetLyrics returns lyrics for the given media file, trying sources in the // order specified by conf.Server.LyricsPriority. func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - var lyricsList model.LyricList - var err error + return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) +} +// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup, +// scanning a bounded window of duplicate matches so source priority still wins +// across them. +func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { + opts := songsByArtistTitleWithLyricsFirst(artist, title) + opts.Max = maxLegacyLyricsCandidates + mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts) + if err != nil { + return nil, err + } + if len(mediaFiles) == 0 { + return nil, nil + } + candidates := make([]*model.MediaFile, 0, len(mediaFiles)) + for i := range mediaFiles { + candidates = append(candidates, &mediaFiles[i]) + } + return l.getLyricsForCandidates(ctx, candidates) +} + +func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions { + return model.QueryOptions{ + Sort: "lyrics, updated_at", + Order: "desc", + Filters: And{ + Eq{"missing": false}, + Eq{"title": title}, + Or{ + persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), + }, + }, + } +} + +func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) - switch { - case strings.EqualFold(pattern, "embedded"): - lyricsList, err = fromEmbedded(ctx, mf) - case strings.HasPrefix(pattern, "."): - lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) - default: - lyricsList, err = l.fromPlugin(ctx, mf, pattern) + if pattern == "" { + continue } - if err != nil { - log.Error(ctx, "error getting lyrics", "source", pattern, err) - } + for _, mf := range mediaFiles { + if mf == nil { + continue + } - if len(lyricsList) > 0 { - return lyricsList, nil + lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) + if err != nil { + log.Error(ctx, "error getting lyrics", "source", pattern, err) + continue + } + + if len(lyricsList) > 0 { + return lyricsList, nil + } } } return nil, nil } + +func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { + switch { + case strings.EqualFold(pattern, "embedded"): + return fromEmbedded(ctx, mf) + case strings.HasPrefix(pattern, "."): + return fromExternalFile(ctx, mf, pattern) + default: + return l.fromPlugin(ctx, mf, pattern) + } +} diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go index f87381905..c9fdcbae8 100644 --- a/core/lyrics/lyrics_suite_test.go +++ b/core/lyrics/lyrics_suite_test.go @@ -1,9 +1,13 @@ package lyrics_test import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,17 @@ func TestLyrics(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Lyrics Suite") } + +// core/storage/local calls log.Fatal if the default scanner extractor is unregistered +// when constructing any localStorage. Register a no-op so storage.For("file://...") works +// in tests without importing the real extractor. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &noopExtractor{} + }) +}) + +type noopExtractor struct{} + +func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *noopExtractor) Version() string { return "noop" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 7e837782e..b00bcd576 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -12,18 +13,23 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("sources", func() { +var _ = Describe("Lyrics", func() { var mf model.MediaFile var ctx context.Context - const badLyrics = "This is a set of lyrics\nThat is not good" - unsynced, _ := model.ToLyrics("xxx", badLyrics) - embeddedLyrics := model.LyricList{*unsynced} + embeddedLyrics := model.LyricList{ + model.Lyrics{ + Lang: "xxx", + Line: []model.Line{ + {Value: "This is a set of lyrics"}, + {Value: "That is not good"}, + }, + }, + } syncedLyrics := model.LyricList{ model.Lyrics{ @@ -32,15 +38,80 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), + Synced: true, + }, + } + + elrcLyrics := model.LyricList{ + model.Lyrics{ + DisplayArtist: "ELRC Artist", + DisplayTitle: "ELRC Song", + Lang: "eng", + Line: []model.Line{ + { + Start: new(int64(1000)), + End: new(int64(3000)), + Value: "Lead words", + Cue: []model.Cue{ + { + Start: new(int64(1000)), + End: new(int64(1500)), + Value: "Lead ", + ByteStart: 0, + ByteEnd: 4, + }, + { + Start: new(int64(1500)), + End: new(int64(3000)), + Value: "words", + ByteStart: 5, + ByteEnd: 9, + }, + }, + }, + { + Start: new(int64(3000)), + Value: "Fallback line", + }, + }, + Synced: true, + }, + } + + ttmlLyrics := model.LyricList{ + model.Lyrics{ + Kind: "main", + Lang: "eng", + Line: []model.Line{ + { + Start: new(int64(18800)), + Value: "We're no strangers to love", + }, + { + Start: new(int64(22800)), + Value: "You know the rules and so do I", + }, + }, + Synced: true, + }, + model.Lyrics{ + Kind: "main", + Lang: "por", + Line: []model.Line{ + { + Start: new(int64(18800)), + Value: "Nao somos estranhos ao amor", + }, + }, Synced: true, }, } @@ -60,6 +131,25 @@ var _ = Describe("sources", func() { }, } + srtLyrics := model.LyricList{ + model.Lyrics{ + Lang: "xxx", + Line: []model.Line{ + { + Start: new(int64(18800)), + End: new(int64(22800)), + Value: "We're from subtitles", + }, + { + Start: new(int64(22801)), + End: new(int64(26000)), + Value: "Another subtitle line", + }, + }, + Synced: true, + }, + } + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -69,19 +159,104 @@ var _ = Describe("sources", func() { Lyrics: string(lyricsJson), Path: "tests/fixtures/test.mp3", } - ctx = context.Background() + ctx = GinkgoT().Context() }) DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { conf.Server.LyricsPriority = priority - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(expected)) }, Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics), Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics), - Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics)) + Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics), + Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics), + Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), + Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) + + It("resolves source priority across duplicate media files", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + embeddedJSON, err := json.Marshal(embeddedLyrics) + Expect(err).To(BeNil()) + + repo := &tests.MockMediaFileRepo{} + repo.SetData(model.MediaFiles{ + { + Lyrics: string(embeddedJSON), + Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", + }, + { + Lyrics: "[]", + Path: "tests/fixtures/test.mp3", + }, + }) + svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).To(BeNil()) + Expect(list).To(Equal(ttmlLyrics)) + }) + + It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() { + dir, err := os.MkdirTemp("", "lyrics-case-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + probe := filepath.Join(dir, "CASECHECK") + Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed()) + _, err = os.Stat(filepath.Join(dir, "casecheck")) + if err == nil { + Skip("filesystem is case-insensitive") + } + Expect(os.IsNotExist(err)).To(BeTrue()) + + conf.Server.LyricsPriority = ".LRC" + Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed()) + + svc := lyrics.NewLyrics(nil, nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]model.Line{ + {Start: new(int64(1000)), Value: "Upper suffix"}, + })) + }) + + It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() { + dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed()) + + conf.Server.LyricsPriority = ".yaml,.lrc" + svc := lyrics.NewLyrics(nil, nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + // ParseLyrics falls back to plain text for any suffix when the content + // doesn't match the structured format, so the .yaml hit is non-empty and + // shadows the lower-priority .lrc entirely. + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).To(Equal([]model.Line{ + {Value: "title: not lyricsfile"}, + })) + }) Context("Errors", func() { var RegularUserContext = XContext @@ -111,7 +286,7 @@ var _ = Describe("sources", func() { It("should fallback to embedded if an error happens when parsing file", func() { conf.Server.LyricsPriority = ".mp3,embedded" - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) @@ -120,7 +295,7 @@ var _ = Describe("sources", func() { It("should return nothing if error happens when trying to parse file", func() { conf.Server.LyricsPriority = ".mp3" - svc := lyrics.NewLyrics(nil) + svc := lyrics.NewLyrics(nil, nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(BeEmpty()) @@ -138,7 +313,7 @@ var _ = Describe("sources", func() { It("should return lyrics from a plugin", func() { conf.Server.LyricsPriority = "test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -148,7 +323,7 @@ var _ = Describe("sources", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mf.Lyrics = "" // No embedded lyrics mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -157,7 +332,7 @@ var _ = Describe("sources", func() { It("should skip plugin if embedded has lyrics", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // embedded wins @@ -166,7 +341,7 @@ var _ = Describe("sources", func() { It("should skip unknown plugin names gracefully", func() { conf.Server.LyricsPriority = "nonexistent-plugin,embedded" mockLoader.notFound = true - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded @@ -176,7 +351,7 @@ var _ = Describe("sources", func() { conf.Server.LyricsPriority = "MyLyricsPlugin" mockLoader.pluginName = "MyLyricsPlugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -185,12 +360,56 @@ var _ = Describe("sources", func() { It("should handle plugin error gracefully", func() { conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" mockLoader.err = fmt.Errorf("plugin error") - svc := lyrics.NewLyrics(mockLoader) + svc := lyrics.NewLyrics(nil, mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded }) }) + + var _ = Describe("GetLyricsByArtistTitle", func() { + var svc lyrics.Lyrics + var repo *tests.MockMediaFileRepo + var ds *tests.MockDataStore + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded" + repo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: repo} + svc = lyrics.NewLyrics(ds, nil) + }) + + It("bounds the query to a duplicate window", func() { + repo.SetData(model.MediaFiles{}) + _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(repo.Options.Max).To(Equal(10)) + }) + + It("returns nil when no media file matches", func() { + repo.SetData(model.MediaFiles{}) + list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) + + It("resolves lyrics from the matched media files", func() { + embeddedList, err := model.ParseLyrics(ctx, ".lrc", "eng", []byte("Embedded lyrics line")) + Expect(err).ToNot(HaveOccurred()) + embedded, _ := embeddedList.Main() + embeddedJSON, err := json.Marshal(model.LyricList{embedded}) + Expect(err).ToNot(HaveOccurred()) + repo.SetData(model.MediaFiles{ + {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, + }) + + list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) + }) + }) }) type mockPluginLoader struct { @@ -207,7 +426,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string { return []string{"test-lyrics-plugin"} } -func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) { if m.notFound { return nil, false } diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 82a10ca41..23c20122d 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -3,9 +3,12 @@ package lyrics import ( "context" "errors" - "os" + "fmt" + "io" + "io/fs" "path" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/ioutils" @@ -23,31 +26,46 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er } func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { - basePath := mf.AbsolutePath() - ext := path.Ext(basePath) + ext := path.Ext(mf.Path) + sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix + ctx = log.NewContext(ctx, "file", sidecarRelPath) - externalLyric := basePath[0:len(basePath)-len(ext)] + suffix + store, err := storage.For(mf.LibraryPath) + if err != nil { + return nil, fmt.Errorf("getting storage for library: %w", err) + } + fsys, err := store.FS() + if err != nil { + return nil, fmt.Errorf("opening library filesystem: %w", err) + } - contents, err := ioutils.UTF8ReadFile(externalLyric) - if errors.Is(err, os.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path", "path", externalLyric) + f, err := fsys.Open(sidecarRelPath) + if errors.Is(err, fs.ErrNotExist) { + log.Trace(ctx, "no lyrics found at path") return nil, nil } else if err != nil { return nil, err } + defer f.Close() - lyrics, err := model.ToLyrics("xxx", string(contents)) + contents, err := io.ReadAll(ioutils.UTF8Reader(f)) if err != nil { - log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err) return nil, err - } else if lyrics == nil { - log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) + } + + list, err := model.ParseLyrics(ctx, suffix, "xxx", contents) + if err != nil { + log.Error(ctx, "error parsing external lyric file", err) + return nil, err + } + + if len(list) == 0 { + log.Trace(ctx, "empty lyrics from external file") return nil, nil } - log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) - - return model.LyricList{*lyrics}, nil + log.Trace(ctx, "retrieved lyrics from external file") + return list, nil } // fromPlugin attempts to load lyrics from a plugin with the given name. diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index b3d502101..7c7922bfd 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -3,15 +3,19 @@ package lyrics import ( "context" "encoding/json" + "path/filepath" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("sources", func() { - ctx := context.Background() + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + }) Describe("fromEmbedded", func() { It("should return nothing for a media file with no lyrics", func() { @@ -26,10 +30,12 @@ var _ = Describe("sources", func() { const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() - expectedList := model.LyricList{*synced, *unsynced} + expectedList := model.LyricList{synced, unsynced} lyricsJson, err := json.Marshal(expectedList) Expect(err).ToNot(HaveOccurred()) @@ -54,93 +60,94 @@ var _ = Describe("sources", func() { }) Describe("fromExternalFile", func() { + var fixturesDir string + + BeforeEach(func() { + // tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly. + abs, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + fixturesDir = abs + }) + + mf := func(name string) *model.MediaFile { + return &model.MediaFile{LibraryPath: fixturesDir, Path: name} + } + It("should return nil for lyrics that don't exist", func() { - mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(0)) }) - It("should return synchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + // fromExternalFile delegates format parsing to model.ParseLyrics; the + // per-format parser output is covered exhaustively in the model package. + // Here we only verify each suffix is read from the library FS and routed. + DescribeTable("should read the sidecar file and route its suffix to a parser", + func(name, suffix string, expectSynced bool) { + lyrics, err := fromExternalFile(ctx, mf(name), suffix) - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Line: []model.Line{ - { - Start: gg.P(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: gg.P(int64(22801)), - Value: "You know the rules and so do I", - }, - }, - Offset: gg.P(int64(-100)), - Synced: true, - }, - })) - }) - - It("should return unsynchronized lyrics from a file", func() { - mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".txt") - - Expect(err).To(BeNil()) - Expect(lyrics).To(Equal(model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - Synced: false, - }, - })) - }) + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeEmpty()) + Expect(lyrics[0].Line).ToNot(BeEmpty()) + Expect(lyrics[0].Synced).To(Equal(expectSynced)) + }, + Entry(".lrc synced", "test.mp3", ".lrc", true), + Entry(".elrc enhanced", "test.mp3", ".elrc", true), + Entry(".txt plain", "test.mp3", ".txt", false), + Entry(".srt subtitles", "test.mp3", ".srt", true), + Entry(".ttml multilingual", "test.mp3", ".ttml", true), + Entry(".yaml lyricsfile", "test.mp3", ".yaml", true), + ) It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { - // The function looks for , so we need to pass - // a MediaFile with .mp3 path and look for .lrc suffix - mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) }) It("should handle UTF-16 LE encoded LRC files", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) + + It("should handle TTML files with UTF-8 BOM marker", func() { + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(1)) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) + Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line")) + }) + + It("should handle UTF-16 BE encoded TTML files", func() { + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Kind).To(Equal("main")) + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[0].Line).To(HaveLen(2)) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) + Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one")) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) + Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) + }) }) }) diff --git a/core/matcher/doc.go b/core/matcher/doc.go new file mode 100644 index 000000000..1024bc274 --- /dev/null +++ b/core/matcher/doc.go @@ -0,0 +1,124 @@ +// Package matcher matches song results from external agents (Last.fm, Deezer, +// etc.) to tracks in the local music library, prioritizing accuracy over recall. +// +// It exposes a single [Matcher] type with two entry points that share the same +// matching algorithm: +// +// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library +// tracks, capped at a requested count. Use it when presenting "similar +// songs" results to a client. +// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched +// track, with no deduplication. Use it when the caller needs to correlate +// each result back to its input position (e.g. to attach a per-song +// similarity score). +// +// # Algorithm Overview +// +// Each input song is resolved to its best-matching library track using four +// strategies, applied in priority order. A song matched by a higher-priority +// strategy is never reconsidered by a lower-priority one: +// +// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID. +// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to +// tracks with the same mbz_recording_id. +// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag. +// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string +// comparison with metadata-specificity scoring (see below). +// +// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable +// identifiers always take precedence over fuzzy text matching. Missing tracks +// (those no longer present on disk) are never matched. +// +// # Fuzzy Matching Details +// +// Title+artist matching uses Jaro-Winkler similarity, with a threshold +// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library +// track must clear the title threshold to be considered. Candidates that clear +// it are ranked by, in order: +// +// 1. Title similarity (Jaro-Winkler score, 0.0–1.0) +// 2. Duration proximity (closer duration scores higher; 1.0 when the agent +// reports no duration) +// 3. Specificity level (0–5, based on metadata precision; higher is better) +// 4. Artist overlap (how many of the song's artists the track credits; more +// shared artists is better) +// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; +// prioritizes tracks that are starred or rated >= 4, but only among +// candidates of equal specificity and overlap) +// 6. Album similarity (Jaro-Winkler, as the final tiebreaker) +// +// The specificity levels, from most to least specific, are: +// +// Level 5: Title + Artist identity + Album MBID +// Level 4: Title + Artist identity + Album name (fuzzy) +// Level 3: Title + Artist name + Album name (fuzzy) +// Level 2: Title + Artist identity +// Level 1: Title + Artist name +// Level 0: Title only +// +// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal, +// when a source supplies one) or its MBID. A plain name match is the weaker fallback +// used for an artist with no identity match (e.g. a cover credited to a different +// artist of the same name). +// +// The title phase always requires an agent artist to scope the library query, so +// Level 0 does not mean "no artist": it applies when a candidate matches on title +// but its own artist differs from the query's (e.g. a cover or a featured-artist +// credit), leaving the title as the only shared field. +// +// A song may carry several artists, and the title phase scopes candidate tracks by +// ANY of them: a track credited to at least one shared artist is considered. When a +// source supplies a Navidrome artist ID, that artist is matched directly, skipping +// name/MBID resolution. Among equally specific candidates, the one sharing more of +// the song's artists wins, so a track crediting every collaborator outranks one +// crediting only a single artist. +// +// Each input song is scored independently, so two songs with the same title and +// artist but different durations can resolve to different library tracks (each +// matches the track closest to its own duration). +// +// # Examples +// +// All examples below exercise the title+artist phase, where the interesting +// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that +// always win over fuzzy matching; they need no illustration.) +// +// Title threshold — a near-miss title still matches; an exact-only threshold +// rejects it: +// +// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} +// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} +// With threshold 85%: match succeeds (similarity ~0.87) +// With threshold 100%: no match (not an exact title) +// +// Specificity ranking — among candidates that clear the title threshold, a +// better album match wins: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1 +// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// Result: t2 (Level 3 beats Level 1 on the album match) +// +// Duration tiebreak — with title and artist equal, the closest duration wins, +// so two near-identical input songs can resolve to different tracks: +// +// Agent returns: +// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05 +// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take) +// Library has: +// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08 +// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02 +// Result: studio for the first song, live for the second +// +// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a +// starred (or rating >= 4) track is preferred, but only when specificity and +// artist overlap are equal. A more specific match always wins regardless of the +// preferred flag: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: +// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 +// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred +// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity) +package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 54c2a368e..25b8fda5f 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -3,12 +3,16 @@ package matcher import ( "context" "fmt" + "maps" "math" + "slices" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/xrash/smetrics" ) @@ -23,176 +27,73 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongs matches agent song results to local library tracks using a multi-phase -// matching algorithm that prioritizes accuracy over recall. +// MatchSongs matches agent songs to library tracks and returns up to count +// tracks in the input's order. See the package documentation for the matching +// algorithm. // -// # Algorithm Overview -// -// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the -// local music library using four matching strategies in priority order: -// -// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID -// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with -// matching mbz_recording_id -// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag -// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison -// with metadata specificity scoring -// -// # Matching Priority -// -// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist. -// This ensures that more reliable identifiers take precedence over fuzzy text matching. -// -// # Fuzzy Matching Details -// -// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable -// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: -// -// 1. Title similarity (Jaro-Winkler score, 0.0-1.0) -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is -// starred or has rating >= 4) -// 4. Specificity level (0-5, based on metadata precision): -// - Level 5: Title + Artist MBID + Album MBID (most specific) -// - Level 4: Title + Artist MBID + Album name (fuzzy) -// - Level 3: Title + Artist name + Album name (fuzzy) -// - Level 2: Title + Artist MBID -// - Level 1: Title + Artist name -// - Level 0: Title only -// 5. Album similarity (Jaro-Winkler, as final tiebreaker) -// -// # Examples -// -// Example 1 - MBID Priority: -// -// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (MBID match takes priority over title+artist) -// -// Example 2 - ISRC Priority: -// -// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"} -// Library has: [ -// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}}, -// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, -// ] -// Result: t1 (ISRC match takes priority over title+artist) -// -// Example 3 - Specificity Ranking: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: [ -// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1 -// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3 -// ] -// Result: t2 (Level 3 beats Level 1 due to album match) -// -// Example 4 - Fuzzy Title Matching: -// -// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} -// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} -// With threshold=85%: Match succeeds (similarity ~0.87) -// With threshold=100%: No match (not exact) -// -// # Parameters -// -// - ctx: Context for database operations -// - songs: Slice of agent.Song results from external providers -// - count: Maximum number of matches to return -// -// # Returns -// -// Returns up to 'count' MediaFiles from the library that best match the input songs, -// preserving the original order from the agent. Songs that cannot be matched are skipped. +// Each library track appears at most once, unless the same input song is +// repeated: identical input songs intentionally yield repeated output tracks, +// while distinct songs that resolve to the same track are deduplicated. Songs +// that cannot be matched are skipped. func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { if len(songs) == 0 { return nil, nil } - - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + matches, err := m.resolveMatches(ctx, songs) if err != nil { return nil, err } - return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil + return orderAndDedup(songs, matches, count), nil } -// MatchSongsIndexed matches agent song results to local library tracks and returns a map -// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. -// This preserves original indices, allowing callers to correlate results back to the input slice. +// MatchSongsIndexed matches agent songs to library tracks and returns a map from +// input-song index to matched track, letting callers correlate results back to +// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs, +// results are not deduplicated. See the package documentation for the matching +// algorithm. func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { if len(songs) == 0 { return nil, nil } + return m.resolveMatches(ctx, songs) +} - byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) - if err != nil { - return nil, err - } - +// resolveMatches resolves each input song to its best-matching library track, +// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC > +// Title); each only fills indices not already matched by a higher-priority loader. +func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { result := make(map[int]model.MediaFile, len(songs)) - for i, t := range songs { - if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { - result[i] = mf + if err := m.matchByID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ID: %w", err) + } + if err := m.matchByMBID(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by MBID: %w", err) + } + if err := m.matchByISRC(ctx, songs, result); err != nil { + return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err) + } + // The title phase is best-effort: a DB failure there must not discard the exact + // matches already found by the higher-priority phases. Only surface it as fatal + // when nothing matched at all. + if err := m.matchByTitle(ctx, songs, result); err != nil { + if len(result) == 0 { + return nil, fmt.Errorf("failed to match tracks by title: %w", err) } + log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err) } return result, nil } -func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { - byID, err = m.loadTracksByID(ctx, songs) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - byMBID, err = m.loadTracksByMBID(ctx, songs, byID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) - } - return byID, byMBID, byISRC, byTitle, nil -} - -// songMatchedIn checks if a song has already been matched in any of the provided match maps. -func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { - _, found := lookupByIdentifiers(s, priorMatches...) - return found -} - -// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { - keys := []string{s.ID, s.MBID, s.ISRC} - for _, m := range maps { - for _, key := range keys { - if key != "" { - if mf, ok := m[key]; ok && mf.ID != "" { - return mf, true - } - } - } - } - return model.MediaFile{}, false -} - -// loadTracksByID fetches MediaFiles from the library using direct ID matching. -func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +// matchByID fills result with direct ID matches. +func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var ids []string for _, s := range songs { if s.ID != "" { ids = append(ids, s.ID) } } - matches := map[string]model.MediaFile{} if len(ids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -201,27 +102,37 @@ func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[ }, }) if err != nil { - return matches, err + return err } + byID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { - if _, ok := matches[mf.ID]; !ok { - matches[mf.ID] = mf + byID[mf.ID] = mf // media_file.id is unique, so no dedup needed + } + for i, s := range songs { + if s.ID == "" { + continue + } + if mf, ok := byID[s.ID]; ok { + result[i] = mf } } - return matches, nil + return nil } -// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByMBID fills result with MusicBrainz Recording ID matches, skipping +// songs already matched by a higher-priority loader. +func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var mbids []string - for _, s := range songs { - if s.MBID != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID != "" { mbids = append(mbids, s.MBID) } } - matches := map[string]model.MediaFile{} if len(mbids) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -230,52 +141,86 @@ func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, pri }, }) if err != nil { - return matches, err + return err } + byMBID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { if id := mf.MbzRecordingID; id != "" { - if _, ok := matches[id]; !ok { - matches[id] = mf + if _, ok := byMBID[id]; !ok { + byMBID[id] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.MBID == "" { + continue + } + if mf, ok := byMBID[s.MBID]; ok { + result[i] = mf + } + } + return nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. -func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// matchByISRC fills result with ISRC tag matches, skipping songs already +// matched by a higher-priority loader. +func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { var isrcs []string - for _, s := range songs { - if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC != "" { isrcs = append(isrcs, s.ISRC) } } - matches := map[string]model.MediaFile{} if len(isrcs) == 0 { - return matches, nil + return nil } res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { - return matches, err + return err } + byISRC := make(map[string]model.MediaFile, len(res)) for _, mf := range res { for _, isrc := range mf.Tags.Values(model.TagISRC) { - if _, ok := matches[isrc]; !ok { - matches[isrc] = mf + if _, ok := byISRC[isrc]; !ok { + byISRC[isrc] = mf } } } - return matches, nil + for i, s := range songs { + if _, done := result[i]; done { + continue + } + if s.ISRC == "" { + continue + } + if mf, ok := byISRC[s.ISRC]; ok { + result[i] = mf + } + } + return nil +} + +// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID +// resolution; name is pre-sanitized (article-stripped). +type queryArtist struct { + id string + name string + mbid string } // songQuery represents a normalized query for matching a song to library tracks. type songQuery struct { title string - artist string - artistMBID string + artists []queryArtist album string albumMBID string durationMs uint32 @@ -286,11 +231,13 @@ type matchScore struct { titleSimilarity float64 durationProximity float64 preferredMatch bool - albumSimilarity float64 specificityLevel int + artistOverlap int + albumSimilarity float64 } // betterThan returns true if this score beats another. +// Identity signals (specificity, overlap) outrank the taste signal (preferred). func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -298,106 +245,313 @@ func (s matchScore) betterThan(other matchScore) bool { if s.durationProximity != other.durationProximity { return s.durationProximity > other.durationProximity } - if s.preferredMatch != other.preferredMatch { - return s.preferredMatch - } if s.specificityLevel != other.specificityLevel { return s.specificityLevel > other.specificityLevel } + if s.artistOverlap != other.artistOverlap { + return s.artistOverlap > other.artistOverlap + } + if s.preferredMatch != other.preferredMatch { + return s.preferredMatch + } return s.albumSimilarity > other.albumSimilarity } // sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization -// when the same track is scored against multiple queries in the inner loop. The `mf` field -// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist -// sanitized slice. +// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid +// copying the large MediaFile struct into each entry of the sanitized slice. type sanitizedTrack struct { - mf *model.MediaFile - title string - artist string - album string + mf *model.MediaFile + title string + artist string + album string + artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal + artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path) } -func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { +func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack { return sanitizedTrack{ - mf: mf, - title: str.SanitizeFieldForSorting(mf.Title), - artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), - album: str.SanitizeFieldForSorting(mf.Album), + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + artistIDs: artistIDs, + artistMBIDs: artistMBIDs, } } -// computeSpecificityLevel determines how well query metadata matches a track (0-5). -// The track's title, artist, and album fields must be pre-sanitized. +// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best +// level achievable across any of the query's artists. Fields must be pre-sanitized. +// +// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the +// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers. +// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited +// to a different artist by the same name). func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { - if q.artistMBID != "" && q.albumMBID != "" && - t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { - return 5 - } - if q.artistMBID != "" && q.album != "" && - t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { - return 4 - } - if q.artist != "" && q.album != "" && - t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { - return 3 - } - if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { - return 2 - } - if q.artist != "" && t.artist == q.artist { - return 1 - } - if t.title == q.title { - return 0 - } - return -1 -} - -// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := m.buildTitleQueries(songs, priorMatches...) - if len(queries) == 0 { - return map[string]model.MediaFile{}, nil - } - - threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - - byArtist := map[string][]songQuery{} - for _, q := range queries { - if q.artist != "" { - byArtist[q.artist] = append(byArtist[q.artist], q) + best := 0 + albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold + for _, a := range q.artists { + _, idMember := t.artistIDs[a.id] + _, mbidMember := t.artistMBIDs[a.mbid] + identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember) + level := 0 + switch { + case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID: + level = 5 + case identity && q.album != "" && albumOK: + level = 4 + case a.name != "" && q.album != "" && t.artist == a.name && albumOK: + level = 3 + case identity: + level = 2 + case a.name != "" && t.artist == a.name: + level = 1 + } + if level > best { + best = level } } + return best +} - matches := map[string]model.MediaFile{} - for artist, artistQueries := range byArtist { - tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"order_artist_name": artist}, - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) - if err != nil { +// indexedQuery pairs a normalized songQuery with the index of the input song +// it came from, so title matches can be written back to result by index. +type indexedQuery struct { + index int + query songQuery +} + +// matchByTitle fills result with fuzzy title+artist matches, skipping songs +// already matched by a higher-priority loader. +func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { + queries := groupQueries(songs, result) + if len(queries) == 0 { + return nil + } + + resolved, err := m.resolveArtists(ctx, queries) + if err != nil || len(resolved.allIDs) == 0 { + return err + } + + tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs) + if err != nil { + return err + } + + tracksByQuery := resolved.bucketTracks(tracks) + threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 + for _, iq := range queries { + sanitized := tracksByQuery[iq.index] + if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { + result[iq.index] = mf + } + } + return nil +} + +// groupQueries builds one normalized title query per still-unmatched song, carrying its full +// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name; +// songs with no usable artist are skipped (the title phase needs at least one to scope the query). +func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery { + var queries []indexedQuery + for i, s := range songs { + if _, done := result[i]; done { continue } - - sanitized := make([]sanitizedTrack, len(tracks)) - for i := range tracks { - sanitized[i] = newSanitizedTrack(&tracks[i]) + var artists []queryArtist + for _, a := range s.Artists { + name := str.SanitizeFieldForSortingNoArticle(a.Name) + if a.ID == "" && name == "" && a.MBID == "" { + continue + } + artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID}) } + if len(artists) == 0 { + continue + } + queries = append(queries, indexedQuery{index: i, query: songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artists: artists, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + }}) + } + return queries +} - for _, q := range artistQueries { - if mf, found := m.findBestMatch(q, sanitized, threshold); found { - key := q.title + "|" + q.artist - if _, exists := matches[key]; !exists { - matches[key] = mf +// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index +// that owns them. Routing is always by stable artist ID, never by name. +type resolvedArtists struct { + byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs + mbid map[string]string // artist ID -> its MBID (from the artist table) + allIDs []string // every resolved artist ID, for the track lookup +} + +// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a +// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in +// one batched query. Ownership is recorded per query index. +func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) { + res := resolvedArtists{ + byQuery: make(map[int]map[string]struct{}, len(queries)), + mbid: make(map[string]string), + } + allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved + + // One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists + // take the fast-path and are owned directly. + type pendingArtist struct { + name, mbid string + query int + } + var pending []pendingArtist + for _, iq := range queries { + for _, a := range iq.query.artists { + if a.id != "" { + addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly + allIDs[a.id] = struct{}{} + continue + } + pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index}) + } + } + // query indices that supplied each order name / each MBID (skip the empty key — an artist may + // have only one of name/mbid). + nameToQueries := map[string][]int{} + mbidToQueries := map[string][]int{} + for _, p := range pending { + if p.name != "" { + nameToQueries[p.name] = append(nameToQueries[p.name], p.query) + } + if p.mbid != "" { + mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query) + } + } + + // Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are + // available for specificity scoring). + var filter squirrel.Or + if len(nameToQueries) > 0 { + filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))}) + } + if len(mbidToQueries) > 0 { + filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) + } + if len(allIDs) > 0 { + filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))}) + } + if len(filter) > 0 { + artists, err := m.ds.Artist(ctx).GetAll(model.QueryOptions{Filters: filter}) + if err != nil { + return resolvedArtists{}, err + } + for _, a := range artists { + res.mbid[a.ID] = a.MbzArtistID + allIDs[a.ID] = struct{}{} + for _, idx := range nameToQueries[a.OrderArtistName] { + addToSet(res.byQuery, idx, a.ID) + } + if a.MbzArtistID != "" { + for _, idx := range mbidToQueries[a.MbzArtistID] { + addToSet(res.byQuery, idx, a.ID) } } } } - return matches, nil + + res.allIDs = slices.Collect(maps.Keys(allIDs)) + return res, nil +} + +func addToSet(m map[int]map[string]struct{}, k int, v string) { + if m[k] == nil { + m[k] = map[string]struct{}{} + } + m[k][v] = struct{}{} +} + +// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct +// artist IDs the track credits. +type scoredTrack struct { + sanitizedTrack + overlap int +} + +// queryAccum tallies, for one track against one query, the overlap count, the credited artists' +// owned IDs, and their MBIDs. +type queryAccum struct { + overlap int + ids map[string]struct{} + mbids map[string]struct{} +} + +func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack { + queriesByArtist := make(map[string][]int) + for idx, ids := range r.byQuery { + for id := range ids { + queriesByArtist[id] = append(queriesByArtist[id], idx) + } + } + + byQuery := make(map[int][]scoredTrack, len(r.byQuery)) + for i := range tracks { + acc := map[int]*queryAccum{} + credited := map[string]struct{}{} + for _, p := range tracks[i].Participants[model.RoleArtist] { + if _, dup := credited[p.ID]; dup { + continue + } + owners, owned := queriesByArtist[p.ID] + if !owned { + continue + } + credited[p.ID] = struct{}{} + mbid := r.mbid[p.ID] // "" if not in the artist-table result + for _, idx := range owners { + a := acc[idx] + if a == nil { + a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}} + acc[idx] = a + } + a.overlap++ + a.ids[p.ID] = struct{}{} + if mbid != "" { + a.mbids[mbid] = struct{}{} + } + } + } + for idx, a := range acc { + byQuery[idx] = append(byQuery[idx], scoredTrack{ + sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids), + overlap: a.overlap, + }) + } + } + return byQuery +} + +// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as +// the main artist (role='artist', not albumartist — that avoids tribute/compilation false +// positives). The non-correlated id IN (subquery) materializes the matching ids once from the +// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs +// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr +// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the +// cleaner home if this is reused. +func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) { + if len(artistIDs) == 0 { + return nil, nil + } + args := slice.Map(artistIDs, func(id string) any { return id }) + return m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists "+ + "WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...), + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) } // durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration @@ -412,34 +566,32 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false - for _, t := range sanitizedTracks { - titleSim := similarityRatio(q.title, t.title) - + preferStarred := conf.Server.Matcher.PreferStarred + for _, c := range candidates { + titleSim := similarityRatio(q.title, c.title) if titleSim < threshold { continue } - var albumSim float64 if q.album != "" { - albumSim = similarityRatio(q.album, t.album) + albumSim = similarityRatio(q.album, c.album) } - score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, t.mf.Duration), - preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), + durationProximity: durationProximity(q.durationMs, c.mf.Duration), + preferredMatch: preferStarred && isPreferredTrack(c.mf), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, t, threshold), + specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold), + artistOverlap: c.overlap, } - if score.betterThan(bestScore) { bestScore = score - bestMatch = *t.mf + bestMatch = *c.mf found = true } } @@ -450,66 +602,34 @@ func isPreferredTrack(mf *model.MediaFile) bool { return mf.Starred || mf.Rating >= 4 } -// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { - var queries []songQuery - for _, s := range songs { - if songMatchedIn(s, priorMatches...) { - continue - } - queries = append(queries, songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artist: str.SanitizeFieldForSortingNoArticle(s.Artist), - artistMBID: s.ArtistMBID, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - }) - } - return queries -} - -// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks using priority order: ID > MBID > ISRC > title+artist. -func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// orderAndDedup builds the final ordered result from the per-index matches, +// applying the count limit and deduplication. A library track is added at most +// once unless the same input song appears more than once (callers rely on that +// 1:1 positional behavior for identical duplicate inputs). +func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - addedBy := make(map[string]agents.Song, len(songs)) + addedBy := make(map[string]int, len(songs)) - for _, t := range songs { + for i, s := range songs { if len(mfs) == count { break } - - mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist) + mf, found := matches[i] if !found { continue } - - if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if t != prevSong { + if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { + if !s.Equals(songs[prevIdx]) { continue } } else { - addedBy[mf.ID] = t + addedBy[mf.ID] = i } - mfs = append(mfs, mf) } return mfs } -// findMatchingTrack looks up a song in the match maps using priority order. -func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { - return mf, true - } - key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) - if mf, ok := byTitleArtist[key]; ok { - return mf, true - } - return model.MediaFile{}, false -} - // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. func similarityRatio(a, b string) float64 { if a == b { diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index f111364c1..5b987d937 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,6 +1,9 @@ package matcher import ( + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -51,3 +54,205 @@ var _ = Describe("similarityRatio", func() { Expect(ratio1).To(Equal(ratio2)) }) }) + +var _ = Describe("matcher internals", func() { + It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() { + q := songQuery{ + title: "song", + artists: []queryArtist{{mbid: "artist-mbid-1"}}, + albumMBID: "album-mbid-1", + } + mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty + t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}}) + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + }) + + It("computeSpecificityLevel maximizes the level over all query artists", func() { + // First artist does not match; second matches by name with a matching album → level 3. + q := songQuery{ + title: "song", + album: "violator", + artists: []queryArtist{ + {name: "no match"}, + {name: "depeche mode"}, + }, + } + mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"} + t := newSanitizedTrack(&mf, nil, nil) + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3)) + }) + + It("scores MBID specificity for any credited artist, not just the last", func() { + q := songQuery{ + title: "song", + artists: []queryArtist{ + {name: "drake", mbid: "mbz-drake"}, + {name: "future", mbid: "mbz-future"}, + }, + album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+ + } + // Track credits BOTH MBIDs; with the old last-wins string this would only match one. + t := sanitizedTrack{ + mf: &model.MediaFile{}, + title: "song", + artist: "drake", + album: "some other album", + artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}}, + } + // Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is + // reached via mbz-future too, which the old code would have dropped. + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2)) + }) + + It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() { + // A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the + // strongest identity signal, so the track's album still elevates specificity above 0. + q := songQuery{ + title: "song", + artists: []queryArtist{{id: "artist-1"}}, + album: "violator", + albumMBID: "album-mbid-1", + } + // Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin). + mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"} + t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil) + // Album MBID matches → level 5 via the ID identity, where the old code scored 0. + Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) + + // Same artist, album name matches but no album MBID → level 4 via the ID identity. + q.albumMBID = "" + mf2 := model.MediaFile{Title: "Song", Album: "Violator"} + t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil) + Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4)) + }) +}) + +var _ = Describe("groupQueries", func() { + It("builds one query per unmatched song carrying all artists", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].index).To(Equal(0)) + Expect(queries[0].query.title).To(Equal("song a")) + Expect(queries[0].query.artists).To(HaveLen(2)) + Expect(queries[0].query.artists[0].name).To(Equal("drake")) + Expect(queries[0].query.artists[1].name).To(Equal("future")) + }) + + It("strips leading articles from artist names", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("drake")) + }) + + It("keeps an artist that carries only an ID (empty name)", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].id).To(Equal("ar-x")) + Expect(queries[0].query.artists[0].name).To(Equal("")) + }) + + It("keeps an artist that carries only an MBID (empty id and name)", func() { + // ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display + // name; the MBID is a usable identity signal and must not be dropped. + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].id).To(Equal("")) + Expect(queries[0].query.artists[0].name).To(Equal("")) + Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future")) + }) + + It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].query.artists).To(HaveLen(1)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + }) + + It("skips already-matched songs and songs with no usable artist", func() { + songs := []agents.Song{ + {Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}}, + {Name: "No Artist"}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}}, + } + queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}}) + Expect(queries).To(HaveLen(1)) + Expect(queries[0].index).To(Equal(2)) + Expect(queries[0].query.artists[0].name).To(Equal("future")) + }) +}) + +var _ = Describe("bucketTracks", func() { + It("scores tracks by how many of the query's artists they credit (overlap)", func() { + r := resolvedArtists{ + byQuery: map[int]map[string]struct{}{ + 0: {"ar-1": {}, "ar-2": {}}, + }, + mbid: map[string]string{}, + } + trackA := model.MediaFile{ID: "a", Title: "A", + Participants: artistParticipants( + model.Artist{ID: "ar-1", OrderArtistName: "one"}, + model.Artist{ID: "ar-2", OrderArtistName: "two"}, + ), + } + trackB := model.MediaFile{ID: "b", Title: "B", + Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}), + } + byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB}) + Expect(byQuery[0]).To(HaveLen(2)) + overlaps := map[string]int{} + for _, st := range byQuery[0] { + overlaps[st.mf.ID] = st.overlap + } + Expect(overlaps["a"]).To(Equal(2)) + Expect(overlaps["b"]).To(Equal(1)) + }) +}) + +var _ = Describe("resolveArtists ID fast-path", func() { + It("owns an artist supplied by ID without a name match", func() { + ctx := GinkgoT().Context() + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{ + {ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"}, + }) + ds := &tests.MockDataStore{MockedArtist: artistRepo} + m := New(ds) + + queries := []indexedQuery{ + {index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}}, + } + res, err := m.resolveArtists(ctx, queries) + Expect(err).ToNot(HaveOccurred()) + Expect(res.byQuery[0]).To(HaveKey("ar-x")) + Expect(res.allIDs).To(ContainElement("ar-x")) + Expect(res.mbid["ar-x"]).To(Equal("mbz-x")) + }) +}) + +// artistParticipants builds a Participants map crediting the given artists under RoleArtist. +func artistParticipants(artists ...model.Artist) model.Participants { + list := make(model.ParticipantList, len(artists)) + for i, a := range artists { + list[i] = model.Participant{Artist: a} + } + return model.Participants{model.RoleArtist: list} +} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 42e3ec88d..a46db8a09 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -3,6 +3,7 @@ package matcher_test import ( "context" "errors" + "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" @@ -19,6 +20,7 @@ import ( var _ = Describe("Matcher", func() { var ds model.DataStore var mediaFileRepo *mockMediaFileRepo + var artistRepo *mockArtistRepo var ctx context.Context var m *matcher.Matcher @@ -26,11 +28,13 @@ var _ = Describe("Matcher", func() { ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) mediaFileRepo = newMockMediaFileRepo() + artistRepo = newMockArtistRepo() DeferCleanup(func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) ds = &tests.MockDataStore{ MockedMediaFile: mediaFileRepo, + MockedArtist: artistRepo, } m = matcher.New(ds) }) @@ -53,26 +57,49 @@ var _ = Describe("Matcher", func() { Return(matches, nil).Once() } - // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return - // early without hitting the DB) don't cause test failures for unexpected calls. Call - // this after expect*Phase for the phases the test actually wants to verify. - allowOtherPhases := func() { + // allowIdentifierPhases installs .Maybe() catch-alls for the ID/MBID/ISRC phases so + // tests that only care about the title phase don't fail on those unexpected calls. + allowIdentifierPhases := func() { mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). Return(model.MediaFiles{}, nil).Maybe() mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). Return(model.MediaFiles{}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + allowIdentifierPhases() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). Return(model.MediaFiles{}, nil).Maybe() } - // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise - // the title+artist phase. The title phase uses .Maybe() because it may short-circuit - // when no songs have an artist. - setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { - mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). - Return(artistTracks, nil).Maybe() + // allowTitlePhase wires title matching from a list of library tracks. Each track must carry + // Participants[RoleArtist] with the artist IDs that credit it; the helper derives the artist + // rows the artist resolution returns from those participants, then returns the tracks from + // the track-fetch query. + allowTitlePhase := func(tracks model.MediaFiles) { + // Artist resolution: build artist rows from the tracks' participants. + seen := map[string]model.Artist{} + for _, t := range tracks { + for _, p := range t.Participants[model.RoleArtist] { + if _, ok := seen[p.ID]; !ok { + seen[p.ID] = p.Artist + } + } + } + artists := make(model.Artists, 0, len(seen)) + for _, a := range seen { + artists = append(artists, a) + } + artistRepo.On("GetAll", mock.Anything).Return(artists, nil).Maybe() + // Track fetch (media_file_artists subquery). + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(tracks, nil).Maybe() } Describe("MatchSongs", func() { @@ -80,7 +107,7 @@ var _ = Describe("Matcher", func() { It("matches songs with an ID field to MediaFiles by ID", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, + {ID: "track-1", Name: "Some Song", Artists: []agents.Artist{{Name: "Some Artist"}}}, } idMatch := model.MediaFile{ ID: "track-1", Title: "Some Song", Artist: "Some Artist", @@ -98,7 +125,7 @@ var _ = Describe("Matcher", func() { It("matches songs with MBID to tracks with matching mbz_recording_id", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, + {Name: "Paranoid Android", MBID: "abc-123", Artists: []agents.Artist{{Name: "Radiohead"}}}, } mbidMatch := model.MediaFile{ ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", @@ -117,7 +144,7 @@ var _ = Describe("Matcher", func() { It("matches songs with ISRC to tracks with matching ISRC tag", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artists: []agents.Artist{{Name: "Radiohead"}}}, } isrcMatch := model.MediaFile{ ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", @@ -136,12 +163,13 @@ var _ = Describe("Matcher", func() { It("matches songs by title and artist name", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, } titleMatch := model.MediaFile{ ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + allowTitlePhase(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -151,12 +179,13 @@ var _ = Describe("Matcher", func() { It("matches songs with fuzzy title similarity", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, } fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -166,12 +195,14 @@ var _ = Describe("Matcher", func() { It("does not match completely different titles", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}}, } differentTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + }, } - setupTitleOnlyExpectations(differentTracks) + allowTitlePhase(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -182,13 +213,14 @@ var _ = Describe("Matcher", func() { It("removes duplicates when different input songs match the same library track", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Live)", Artists: []agents.Artist{{Name: "Queen"}}}, + {Name: "Bohemian Rhapsody (Original Mix)", Artists: []agents.Artist{{Name: "Queen"}}}, } libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -198,13 +230,14 @@ var _ = Describe("Matcher", func() { It("preserves duplicates when identical input songs match the same library track", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, } libraryTrack := model.MediaFile{ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -220,7 +253,7 @@ var _ = Describe("Matcher", func() { // and short-circuit the MBID phase entirely, so no MBID fetch should // occur even though an mbz_recording_id exists in the input. songs := []agents.Song{ - {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artists: []agents.Artist{{Name: "Artist"}}}, } idMatch := model.MediaFile{ ID: "track-id", Title: "Song", Artist: "Artist", @@ -238,16 +271,22 @@ var _ = Describe("Matcher", func() { It("returns at most 'count' results", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, } tracks := model.MediaFiles{ - {ID: "a", Title: "Song A", Artist: "Artist"}, - {ID: "b", Title: "Song B", Artist: "Artist"}, - {ID: "c", Title: "Song C", Artist: "Artist"}, + {ID: "a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, + {ID: "c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } - setupTitleOnlyExpectations(tracks) + allowTitlePhase(tracks) result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -261,14 +300,277 @@ var _ = Describe("Matcher", func() { Expect(result).To(BeEmpty()) }) }) + + Context("artist grouping", func() { + It("groups title-phase tracks by participant artist ID, not display Artist", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Daft Punk"}}}, + } + // Display Artist differs from the query artist; only the participant + // with order_artist_name "daft punk" routes to this query bucket. + track := model.MediaFile{ + ID: "oan-track", Title: "Song A", + Artist: "Daft Punk feat. Pharrell", + Participants: artistParticipants( + model.Artist{ID: "dp", Name: "Daft Punk", OrderArtistName: "daft punk"}, + model.Artist{ID: "ph", Name: "Pharrell", OrderArtistName: "pharrell"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("oan-track")) + }) + + It("matches a track that credits the searched artist as a collaborator", func() { + songs := []agents.Song{ + {Name: "Crazy", Artists: []agents.Artist{{Name: "INXS"}}}, + } + // "Par-T-One vs. INXS" — display Artist is the collaboration, but INXS is a + // credited artist participant. Searching INXS must match it. + track := model.MediaFile{ + ID: "collab", Title: "Crazy", Artist: "Par-T-One vs. INXS", + Participants: artistParticipants( + model.Artist{ID: "a-partone", Name: "Par-T-One", OrderArtistName: "par-t-one"}, + model.Artist{ID: "a-inxs", Name: "INXS", OrderArtistName: "inxs"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("collab")) + }) + + It("does not match a track where the searched artist is only the album artist", func() { + songs := []agents.Song{ + {Name: "Qmart", Artists: []agents.Artist{{Name: "808 State"}}}, + } + // Track performed by Björk on an "808 State" compilation: 808 State is the + // albumartist, Björk is the performer. Searching 808 State must NOT match it. + track := model.MediaFile{ + ID: "comp", Title: "Qmart", Artist: "Björk", + Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-bjork", Name: "Björk", OrderArtistName: "bjork"}}, + }, + model.RoleAlbumArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, + }, + }, + } + // Artist resolution returns "808 state" only if some artist row matches; here the + // album-artist participant exists but is NOT role='artist', so the track-fetch query's + // EXISTS (role='artist') would not return the track in production. The mock + // returns it anyway; back-mapping must drop it because no role='artist' + // participant is a resolved artist for the query "808 state". + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("resolves the artist by ArtistMBID when the name differs", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Typo Artist", MBID: "mbid-9"}}}, + } + track := model.MediaFile{ + ID: "by-mbid", Title: "Song A", Artist: "Correct Artist", + Participants: artistParticipants(model.Artist{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}), + } + // Artist resolution returns the artist matched by mbz_artist_id; its order name + // ("correct artist") differs from the query name ("typo artist"), so + // resolution must come from the MBID branch. + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{track}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-mbid")) + }) + + It("resolves both queries when two share one ArtistMBID under different names", func() { + // Two agent results for the same MusicBrainz artist but spelled differently + // (an alias). Both must match the artist's track via the shared MBID. + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Alias One", MBID: "mbid-shared"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Alias Two", MBID: "mbid-shared"}}}, + } + artist := model.Artist{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"} + trackA := model.MediaFile{ID: "ta", Title: "Song A", Artist: "Canonical", Participants: artistParticipants(artist)} + trackB := model.MediaFile{ID: "tb", Title: "Song B", Artist: "Canonical", Participants: artistParticipants(artist)} + artistRepo.On("GetAll", mock.Anything). + Return(model.Artists{{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(model.MediaFiles{trackA, trackB}, nil).Maybe() + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect([]string{result[0].ID, result[1].ID}).To(ConsistOf("ta", "tb")) + }) + }) + + // These tests register their own track-fetch expectations per-test (to inject + // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would + // add a .Maybe() title-phase catch-all that masks the injected error. + Context("title phase DB errors", func() { + It("returns an error when the title query fails and nothing else matched", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, + } + allowIdentifierPhases() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "a1", Name: "Artist One", OrderArtistName: "artist one"}, + {ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(nil, errors.New("db down")) + + _, err := m.MatchSongs(ctx, songs, 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("db down")) + }) + + It("keeps exact-phase matches when the title query fails", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Exact Song", Artists: []agents.Artist{{Name: "Exact Artist"}}}, + {Name: "Fuzzy Song", Artists: []agents.Artist{{Name: "Fuzzy Artist"}}}, + } + idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} + expectIDPhase(model.MediaFiles{idMatch}) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ + {ID: "fa", Name: "Fuzzy Artist", OrderArtistName: "fuzzy artist"}, + }, nil) + mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). + Return(nil, errors.New("db down")) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) + + Context("multiple artists", func() { + It("prefers the track that shares more of the song's artists", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + // Both candidates have display Artist "Drake", so they tie at specificity level 1 + // (name match). The deciding factor is artistOverlap: "both" credits Drake AND + // Future (overlap 2), "one" credits only Drake (overlap 1). + bothArtists := model.MediaFile{ + ID: "both", Title: "Life Is Good", Artist: "Drake", + Participants: artistParticipants( + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, + ), + } + oneArtist := model.MediaFile{ + ID: "one", Title: "Life Is Good", Artist: "Drake", + Participants: artistParticipants( + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + ), + } + allowTitlePhase(model.MediaFiles{oneArtist, bothArtists}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("both")) + }) + + It("matches a single-artist song against a track crediting several artists", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Future"}}}, + } + track := model.MediaFile{ + ID: "multi", Title: "Life Is Good", Artist: "Future feat. Drake", + Participants: artistParticipants( + model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, + model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("multi")) + }) + + It("matches by a directly-supplied Navidrome artist ID (fast-path)", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, + } + track := model.MediaFile{ + ID: "by-id", Title: "Song A", Artist: "Some Artist", + Participants: artistParticipants( + model.Artist{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist"}, + ), + } + allowTitlePhase(model.MediaFiles{track}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("by-id")) + }) + + It("prefers a higher artist-overlap track over a starred lower-overlap track", func() { + conf.Server.Matcher.PreferStarred = true + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Collab Hit", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, + } + // Shares only Drake (overlap 1) but starred. + starredOne := model.MediaFile{ + ID: "starred-one", Title: "Collab Hit", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}), + } + // Shares both (overlap 2), not starred. + shareTwo := model.MediaFile{ + ID: "share-two", Title: "Collab Hit", + Participants: artistParticipants( + model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}, + model.Artist{ID: "id-future", Name: "Future", OrderArtistName: "future"}, + ), + } + allowTitlePhase(model.MediaFiles{starredOne, shareTwo}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("share-two")) // overlap outranks the starred flag + }) + }) }) Describe("MatchSongsIndexed", func() { It("returns index-keyed map of matched songs", func() { songs := []agents.Song{ - {ID: "track-1", Name: "Song One", Artist: "Artist A"}, - {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, - {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + {ID: "track-1", Name: "Song One", Artists: []agents.Artist{{Name: "Artist A"}}}, + {ID: "track-2", Name: "Song Two", Artists: []agents.Artist{{Name: "Artist B"}}}, + {ID: "track-3", Name: "Song Three", Artists: []agents.Artist{{Name: "Artist C"}}}, } mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} @@ -287,8 +589,8 @@ var _ = Describe("Matcher", func() { It("preserves original indices when some songs don't match", func() { songs := []agents.Song{ - {Name: "Unknown Song", Artist: "Unknown Artist"}, - {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + {Name: "Unknown Song", Artists: []agents.Artist{{Name: "Unknown Artist"}}}, + {ID: "track-1", Name: "Known Song", Artists: []agents.Artist{{Name: "Known Artist"}}}, } mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} @@ -319,16 +621,18 @@ var _ = Describe("Matcher", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid-123"}}, Album: "Violator", AlbumMBID: "album-mbid-456"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -340,15 +644,17 @@ var _ = Describe("Matcher", func() { It("matches by title + artist name + album name when MBIDs unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -360,15 +666,17 @@ var _ = Describe("Matcher", func() { It("matches by title + artist only when album info unavailable", func() { correctMatch := model.MediaFile{ ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), } songs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode"}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -382,7 +690,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song"}, } - setupTitleOnlyExpectations(model.MediaFiles{}) + allowTitlePhase(model.MediaFiles{}) result, err := m.MatchSongs(ctx, songs, 5) @@ -391,17 +699,23 @@ var _ = Describe("Matcher", func() { }) It("returns distinct matches for each artist's version (covers scenario)", func() { - cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} - cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} - cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} - - songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, - {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), + } + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "ray-charles", Name: "Ray Charles", OrderArtistName: "ray charles"}), + } + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", + Participants: artistParticipants(model.Artist{ID: "sinatra", Name: "Frank Sinatra", OrderArtistName: "frank sinatra"}), } - setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + songs := []agents.Song{ + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "Ray Charles"}}, Album: "Greatest Hits"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "Frank Sinatra"}}, Album: "My Way"}, + } + + allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) result, err := m.MatchSongs(ctx, songs, 5) @@ -415,21 +729,24 @@ var _ = Describe("Matcher", func() { preciseMatch := model.MediaFile{ ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } lessAccurateMatch := model.MediaFile{ ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", + MbzArtistID: "mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), } artistTwoMatch := model.MediaFile{ ID: "artist-two", Title: "Song B", Artist: "Artist Two", + Participants: artistParticipants(model.Artist{ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}), } songs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artist: "Artist Two"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, } - setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -438,6 +755,31 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("precise")) Expect(result[1].ID).To(Equal("artist-two")) }) + + It("uses the resolved artist MBID for specificity (level 5)", func() { + songs := []agents.Song{ + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, + } + // Two tracks with the same title and album; only the one whose resolved artist + // carries mbid-1 (and whose album MBID matches) wins via Level 5. Without the + // resolved MBID, both tracks tie at Level 3 (name+album) and the first wins by + // chance — verifiable by RED-proof: see task-2-report.md. + precise := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "album-mbid-1", + Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), + } + other := model.MediaFile{ + ID: "other", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "wrong-album-mbid", + Participants: artistParticipants(model.Artist{ID: "a1b", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: ""}), + } + // Artist resolution returns both a1 (by name+mbid) and a1b (by name). + allowTitlePhase(model.MediaFiles{other, precise}) + + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("precise")) + }) }) Describe("fuzzy matching thresholds", func() { @@ -446,13 +788,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, + {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -465,13 +809,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, } artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -486,13 +832,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, + {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", + Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -506,13 +854,15 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 75 songs := []agents.Song{ - {Name: "Song", Artist: "Artist"}, + {Name: "Song", Artists: []agents.Artist{{Name: "Artist"}}}, } artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + }, } - setupTitleOnlyExpectations(artistTracks) + allowTitlePhase(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -531,16 +881,18 @@ var _ = Describe("Matcher", func() { It("matches album with (Remaster) suffix", func() { songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -551,16 +903,18 @@ var _ = Describe("Matcher", func() { It("matches album with (Deluxe Edition) suffix", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } wrongMatch := model.MediaFile{ ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -571,16 +925,18 @@ var _ = Describe("Matcher", func() { It("prefers exact album match over fuzzy album match", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } exactMatch := model.MediaFile{ ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } fuzzyMatch := model.MediaFile{ ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -589,46 +945,68 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("exact")) }) - It("prefers starred songs over better album match when enabled", func() { + It("prefers a more specific match over a starred track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } starredTrack := model.MediaFile{ - ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) - + allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("starred")) + Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the starred flag }) - It("prefers 4-star songs over better album match when enabled", func() { + It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, } albumMatch := model.MediaFile{ ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } ratedTrack := model.MediaFile{ - ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", + Annotations: model.Annotations{Rating: 4}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), } - - setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) - + allowTitlePhase(model.MediaFiles{albumMatch, ratedTrack}) result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("rated")) + Expect(result[0].ID).To(Equal("album-match")) // specificity now outranks the 4-star rating + }) + + It("prefers a starred track when specificity and overlap are equal", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + } + // Both credit the same single artist and the same album → equal specificity AND equal overlap. + plain := model.MediaFile{ + ID: "plain", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + } + starred := model.MediaFile{ + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + Annotations: model.Annotations{Starred: true}, + Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + } + allowTitlePhase(model.MediaFiles{plain, starred}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("starred")) // preferred still wins the tie }) }) @@ -639,16 +1017,18 @@ var _ = Describe("Matcher", func() { It("prefers tracks with matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } correctMatch := model.MediaFile{ ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } wrongDuration := model.MediaFile{ ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -659,13 +1039,14 @@ var _ = Describe("Matcher", func() { It("matches tracks with close duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } closeDuration := model.MediaFile{ ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + allowTitlePhase(model.MediaFiles{closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -676,16 +1057,18 @@ var _ = Describe("Matcher", func() { It("prefers closer duration over farther duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } closeDuration := model.MediaFile{ ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } farDuration := model.MediaFile{ ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -696,13 +1079,14 @@ var _ = Describe("Matcher", func() { It("still matches when no tracks have matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } differentDuration := model.MediaFile{ ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + allowTitlePhase(model.MediaFiles{differentDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -713,16 +1097,18 @@ var _ = Describe("Matcher", func() { It("prefers title match over duration match when titles differ", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, } differentTitle := model.MediaFile{ ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } correctTitle := model.MediaFile{ ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) result, err := m.MatchSongs(ctx, songs, 5) @@ -733,13 +1119,14 @@ var _ = Describe("Matcher", func() { It("matches without duration filtering when agent duration is 0", func() { songs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 0}, } anyTrack := model.MediaFile{ ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + allowTitlePhase(model.MediaFiles{anyTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -750,13 +1137,14 @@ var _ = Describe("Matcher", func() { It("handles very short songs with close duration", func() { songs := []agents.Song{ - {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + {Name: "Short Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 30000}, } shortTrack := model.MediaFile{ ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), } - setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + allowTitlePhase(model.MediaFiles{shortTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -764,6 +1152,30 @@ var _ = Describe("Matcher", func() { Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("short")) }) + + It("matches same title+artist songs to their own closest-duration track", func() { + songs := []agents.Song{ + {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 180000}, + {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 240000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), + } + longTrack := model.MediaFile{ + ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, + Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), + } + + allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("short")) + Expect(result[1].ID).To(Equal("long")) + }) }) Describe("deduplication edge cases", func() { @@ -773,16 +1185,17 @@ var _ = Describe("Matcher", func() { It("handles mixed scenario with both identical and different input songs", func() { songs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "1"}, + {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, + {Name: "Yesterday (Anthology)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Anthology"}, } libraryTrack := model.MediaFile{ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), } - setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + allowTitlePhase(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -794,15 +1207,21 @@ var _ = Describe("Matcher", func() { It("does not deduplicate songs that match different library tracks", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) result, err := m.MatchSongs(ctx, songs, 5) @@ -815,15 +1234,19 @@ var _ = Describe("Matcher", func() { It("respects count limit after deduplication", func() { songs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song A (Live)", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song B (Remix)", Artist: "Artist"}, + {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song A (Live)", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song B (Remix)", Artists: []agents.Artist{{Name: "Artist"}}}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), + } + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", + Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + allowTitlePhase(model.MediaFiles{trackA, trackB}) result, err := m.MatchSongs(ctx, songs, 2) @@ -866,6 +1289,27 @@ func (m *mockMediaFileRepo) SetError(hasError bool) { } } +type mockArtistRepo struct { + mock.Mock + model.ArtistRepository +} + +func newMockArtistRepo() *mockArtistRepo { + return &mockArtistRepo{} +} + +func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.Artists), args.Error(1) +} + // matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a // squirrel.And whose first element is a squirrel.Eq containing the given field name. func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { @@ -895,3 +1339,30 @@ func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { return hasField } } + +// artistParticipants builds a Participants map crediting the given artists under RoleArtist. +func artistParticipants(artists ...model.Artist) model.Participants { + list := make(model.ParticipantList, len(artists)) + for i, a := range artists { + list[i] = model.Participant{Artist: a} + } + return model.Participants{model.RoleArtist: list} +} + +// matchTracksByArtistQuery matches the title phase's track-fetch query, identified by its +// squirrel.And containing a squirrel.Expr whose SQL references media_file_artists. +func matchTracksByArtistQuery() func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok { + return false + } + for _, f := range and { + sql, _, err := f.ToSql() + if err == nil && strings.Contains(sql, "media_file_artists") { + return true + } + } + return false + } +} diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f069d3fb6..78391779a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package") packageFileData, err := os.ReadFile(packageFilename) if err == nil { data.OS.Package = string(packageFileData) @@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data { // FS info data.FS.Music = getFSInfo(conf.Server.MusicFolder) - data.FS.Data = getFSInfo(conf.Server.DataFolder) - if conf.Server.CacheFolder != "" { - data.FS.Cache = getFSInfo(conf.Server.CacheFolder) + data.FS.Data = getFSInfo(conf.Server.DataFolder.String()) + if conf.Server.CacheFolder.String() != "" { + data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String()) } - if conf.Server.Backup.Path != "" { - data.FS.Backup = getFSInfo(conf.Server.Backup.Path) + if conf.Server.Backup.Path.String() != "" { + data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String()) } // Config info @@ -223,6 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 34648a49b..126d759bc 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -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"` } diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 035e18dd5..6696eca2a 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error { func (j *Executor) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index 14170efd4..1038b9190 100644 --- a/core/playback/mpv/track.go +++ b/core/playback/mpv/track.go @@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool { func waitForSocket(path string, timeout time.Duration, pause time.Duration) error { start := time.Now() end := start.Add(timeout) - var retries int = 0 + var retries = 0 for { fileInfo, err := os.Stat(path) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index 56c80a950..a5b8b7c02 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R } err = json.Unmarshal(input, nsp) if err != nil { - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { + if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok { line, col := getPositionFromOffset(input, syntaxErr.Offset) return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) } diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go index 516a5355d..d6d69866f 100644 --- a/core/playlists/parse_nsp_test.go +++ b/core/playlists/parse_nsp_test.go @@ -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"} diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 52d5c88d8..f849a0a21 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() { It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("denies non-owner, non-admin from updating", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("returns error when playlist not found", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(Equal(model.ErrNotFound)) }) @@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() { It("allows metadata updates on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Smart" - err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) }) @@ -307,7 +302,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, @@ -371,7 +366,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a real image file on disk imgDir := filepath.Join(tmpDir, "artwork", "playlist") diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index c9b7c4ea6..3f886aadd 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -4,11 +4,13 @@ import ( "context" "errors" "reflect" + "strings" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" ) // --- REST adapter (follows Share/Library pattern) --- @@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) +func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { +// +// cols names the fields the client actually sent in the JSON body (extracted by +// rest.Put). When non-empty, fields outside cols are not considered changed and +// are left untouched — this prevents partial requests like bulk "Make Public" +// (body: {"public": true}) from wiping fields that just happen to be zero in +// the deserialized entity (see issue #5541). An empty cols means "treat the +// entity as a complete record" — preserved for callers that don't use the REST +// wrapper. +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity return err } } + + sent := sentFields(cols) + usr, _ := request.UserFrom(ctx) - if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { + ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID + if !usr.IsAdmin && ownerChanged { return rest.ErrPermissionDenied } - contentChanged := entity.Name != current.Name || - entity.Comment != current.Comment || - (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || - !rulesEqual(current.Rules, entity.Rules) + nameChanged := sent("name") && entity.Name != current.Name + commentChanged := sent("comment") && entity.Comment != current.Comment + rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) - if contentChanged { - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID - } + if nameChanged || commentChanged || ownerChanged || rulesChanged { + return s.applyContentUpdate(ctx, current, entity, sent, + nameChanged, commentChanged, ownerChanged, rulesChanged) + } + return s.applyFlagsOnly(ctx, current, entity, sent) +} + +// applyContentUpdate handles updates that change at least one of name/comment/ +// owner/rules. It goes through updateMetadata, which always bumps updatedAt +// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the +// field is absent from the request OR present-but-unchanged (so updateMetadata +// skips them); publicPtr is nil only when public is absent from the request +// (an idempotent public value is still forwarded). +func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool, +) error { + if ownerChanged { + current.OwnerID = entity.OwnerID + } + if rulesChanged { current.Rules = entity.Rules - if current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - - // Only sync/public changed — skip updatedAt so cover art URLs stay stable - var cols []string - if current.Path != "" && current.Sync != entity.Sync { + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync - cols = append(cols, "sync") } - if current.Public != entity.Public { + var namePtr, commentPtr *string + var publicPtr *bool + if nameChanged { + namePtr = &entity.Name + } + if commentChanged { + commentPtr = &entity.Comment + } + if sent("public") { + publicPtr = &entity.Public + } + return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr) +} + +// applyFlagsOnly handles updates that only toggle sync/public — skips +// updatedAt so cover art URLs stay stable. +func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, +) error { + var updateCols []string + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + updateCols = append(updateCols, "sync") + } + if sent("public") && current.Public != entity.Public { current.Public = entity.Public - cols = append(cols, "public") + updateCols = append(updateCols, "public") } - if len(cols) == 0 { + if len(updateCols) == 0 { return nil } - return s.ds.Playlist(ctx).Put(current, cols...) + return s.ds.Playlist(ctx).Put(current, updateCols...) +} + +// sentFields returns a predicate that reports whether a JSON field was present +// in the request body. Matching is case-insensitive to mirror Go's json +// decoder, which populates struct fields from case-variant keys like +// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity +// as a full record" — every field is considered sent. +func sentFields(cols []string) func(string) bool { + if len(cols) == 0 { + return func(string) bool { return true } + } + set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} }) + return func(field string) bool { + _, ok := set[strings.ToLower(field)] + return ok + } } func rulesEqual(a, b *criteria.Criteria) bool { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 90d22327a..79d72d147 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() { It("clears server-managed fields to prevent injection via REST API", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) - now := time.Now() pls := &model.Playlist{ Name: "Legit Playlist", Comment: "A comment", @@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() { Sync: true, UploadedImage: "injected-image-path", ExternalImageURL: "http://evil.example.com/ssrf", - EvaluatedAt: &now, + EvaluatedAt: new(time.Now()), } _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) @@ -126,6 +125,25 @@ var _ = Describe("REST Adapter", func() { Expect(err).To(Equal(rest.ErrPermissionDenied)) }) + DescribeTable("denies regular user from changing ownership under any case-variant JSON key", + func(colName string) { + // rest.Put's field-name extraction is case-sensitive, but Go's + // json decoder is case-insensitive on struct fields, so any + // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates + // entity.OwnerID. sentFields normalizes both sides so the + // permission gate fires regardless of casing. + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{OwnerID: "other-user"} + err := repo.Update("pls-1", pls, colName) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }, + Entry("canonical camelCase", "ownerId"), + Entry("PascalCase", "OwnerId"), + Entry("all upper", "OWNERID"), + Entry("all lower", "ownerid"), + ) + It("updates smart playlist rules", func() { mockPlsRepo.Data["smart-1"] = &model.Playlist{ ID: "smart-1", @@ -219,6 +237,156 @@ var _ = Describe("REST Adapter", func() { err := repo.Update("nonexistent", pls) Expect(err).To(Equal(rest.ErrNotFound)) }) + + // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public") + // must only touch the fields the client actually sent. The cols list from + // rest.Put names those fields; fields outside it must be left alone, even + // when the deserialized entity has zero values for them. + Context("with partial updates (cols)", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + mockPlsRepo.Data["partial"] = &model.Playlist{ + ID: "partial", + Name: "Original Name", + Comment: "Original comment", + OwnerID: "user-1", + Public: false, + } + }) + + It("preserves name and comment when only public is sent (bulk Make Public)", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("preserves name when only sync is sent for a file-backed playlist", func() { + mockPlsRepo.Data["file-partial"] = &model.Playlist{ + ID: "file-partial", + Name: "Keep Me", + OwnerID: "user-1", + Path: "/music/p.m3u", + Sync: true, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + + It("renames the playlist when only name is sent", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeFalse()) + }) + + It("clears the comment when an empty comment is sent explicitly", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + }) + + It("updates rules-only on a smart playlist (Feishin-style edit)", func() { + mockPlsRepo.Data["smart-partial"] = &model.Playlist{ + ID: "smart-partial", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Public: true, + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} + err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("updates name and rules together (smart-playlist Edit form)", func() { + mockPlsRepo.Data["smart-edit"] = &model.Playlist{ + ID: "smart-edit", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} + err := repo.Update("smart-edit", + &model.Playlist{Name: "Smart Renamed", Rules: newRules}, + "name", "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + }) + + It("does not bump the saved rules on an idempotent rules-only PUT", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{ + ID: "smart-idempotent", + Name: "Smart Idempotent", + OwnerID: "user-1", + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + // Same rules sent back — rulesEqual should report no change and + // the request should no-op (no Put call). + sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened + }) + + It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-public"] = &model.Playlist{ + ID: "smart-public", + Name: "Smart Public", + OwnerID: "user-1", + Public: false, + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-public", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public")) + }) + + It("does not treat a missing ownerId as an ownership transfer attempt", func() { + // A non-admin user sending only {public:true} should not be blocked + // just because OwnerID is the zero value in the deserialized entity. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + }) + + It("matches cols case-insensitively (mirrors json decoder behavior)", func() { + // Go's json decoder populates struct fields from case-variant keys + // like {"Name":"x"}, but rest.Put's field-name extraction is + // case-sensitive. sentFields normalizes both sides so a request + // with {"Name":"Renamed"} is honored, not silently ignored. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + }) + }) }) Describe("Delete", func() { diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 67593e9eb..408ab410d 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // Loader is a function that loads a scrobbler by name. @@ -129,6 +130,14 @@ func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { } func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { + // Scrobbles are drained on a background context that no longer carries the + // request's authenticated user. Restore it from the buffered userId so that + // scrobblers relying on the user in the context (e.g. plugins) still get it. + if user, err := b.ds.User(ctx).Get(userId); err != nil { + log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err) + } else { + ctx = request.WithUser(ctx, *user) + } buffer := b.ds.ScrobbleBuffer(ctx) for { entry, err := buffer.Next(b.service, userId) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index 9fbca6f71..d11f0b003 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -20,8 +20,11 @@ var _ = Describe("BufferedScrobbler", func() { BeforeEach(func() { ctx = context.Background() buffer = tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{ MockedScrobbleBuffer: buffer, + MockedUser: userRepo, } scr = &fakeScrobbler{Authorized: true} bs = newBufferedScrobbler(ds, scr, "test") @@ -62,6 +65,16 @@ var _ = Describe("BufferedScrobbler", func() { Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now)) }) + It("restores the user in the context when draining buffered scrobbles", func() { + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + scrobble := Scrobble{MediaFile: track, TimeStamp: time.Now()} + + Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) + + Eventually(scr.ScrobbleCalled.Load).Should(BeTrue()) + Expect(scr.GetUsername()).To(Equal("alice")) + }) + It("stops the background goroutine when Stop is called", func() { // Replace the real run method with one that signals when it exits done := make(chan struct{}) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index bdb261ef2..a2d7e8639 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - if !params.IgnoreScrobble && player.ScrobbleEnabled && + // NowPlaying gating, by design distinct from scrobble submission: + // - IgnoreScrobble=true -> still send NowPlaying (suppresses only the + // scrobble submission/play-count above), mirroring the legacy scrobble + // endpoint's submission=false behavior. + // - player.ScrobbleEnabled=false -> never send NowPlaying. + // External agents here are the active scrobblers (Last.fm, ListenBrainz, and + // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. + if player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) @@ -484,3 +491,10 @@ func Register(name string, init Constructor) { } constructors[name] = init } + +// IsBuiltinScrobbler reports whether name belongs to a registered builtin +// scrobbler (e.g. "lastfm", "listenbrainz"). +func IsBuiltinScrobbler(name string) bool { + _, ok := constructors[name] + return ok +} diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 684b887fe..831b0ce0d 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -104,6 +104,13 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) + Describe("IsBuiltinScrobbler", func() { + It("reports whether the name belongs to a registered builtin scrobbler", func() { + Expect(IsBuiltinScrobbler("fake")).To(BeTrue()) + Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse()) + }) + }) + Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track @@ -283,7 +290,7 @@ var _ = Describe("PlayTracker", func() { Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) - Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) + Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix())) }) It("does not record scrobble when history is disabled", func() { @@ -521,6 +528,7 @@ var _ = Describe("PlayTracker", func() { }) It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + fake.ScrobbleCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) @@ -531,6 +539,7 @@ var _ = Describe("PlayTracker", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) + Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse()) }) It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { @@ -715,14 +724,14 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) - It("does NOT dispatch when ignoreScrobble=true", func() { + It("still dispatches NowPlaying when ignoreScrobble=true", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("does NOT dispatch when ScrobbleEnabled=false", func() { @@ -1089,6 +1098,13 @@ func (f *fakeScrobbler) GetUserID() string { return "" } +func (f *fakeScrobbler) GetUsername() string { + if p := f.username.Load(); p != nil { + return *p + } + return "" +} + func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } @@ -1120,6 +1136,16 @@ func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *mo func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { f.userID.Store(&userId) + // Capture username from context (this is what plugin scrobblers do) + username, _ := request.UsernameFrom(ctx) + if username == "" { + if u, ok := request.UserFrom(ctx); ok { + username = u.UserName + } + } + if username != "" { + f.username.Store(&username) + } f.LastScrobble.Store(&s) f.ScrobbleCalled.Store(true) if f.Error != nil { @@ -1133,8 +1159,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession if f.Error != nil { return f.Error } - uid := info.UserId - f.userID.Store(&uid) + f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) return nil } diff --git a/core/share.go b/core/share.go index a6d06a018..5a611c7f0 100644 --- a/core/share.go +++ b/core/share.go @@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error if !expiresAt.IsZero() && expiresAt.Before(time.Now()) { return nil, model.ErrExpired } - share.LastVisitedAt = P(time.Now()) + share.LastVisitedAt = new(time.Now()) share.VisitCount++ err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") @@ -95,10 +95,10 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { } s.ID = id if V(s.ExpiresAt).IsZero() { - s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration)) + s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + firstId, _, _ := strings.Cut(s.ResourceIDs, ",") v, err := model.GetEntityByID(r.ctx, r.ds, firstId) if err != nil { return "", err diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go index 81739b726..813fceea9 100644 --- a/core/sonic/sonic_test.go +++ b/core/sonic/sonic_test.go @@ -101,7 +101,7 @@ var _ = Describe("Sonic", func() { provider := &mockProvider{ similarResults: []sonic.SimilarResult{ - {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85}, }, } loader.names = []string{"test-plugin"} diff --git a/core/storage/interface.go b/core/storage/interface.go index dc08ca00a..02c1d14d9 100644 --- a/core/storage/interface.go +++ b/core/storage/interface.go @@ -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. diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 5384581e0..32aff0955 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -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) { diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index aef89cdd5..90bdd4b5b 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -45,16 +44,13 @@ var _ = Describe("LocalStorage", func() { }) Describe("newLocalStorage", func() { - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") - }) Context("with valid path", func() { It("should create a localStorage instance with correct path", func() { - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage := storage.(*localStorage) Expect(localStorage.u.Scheme).To(Equal("file")) @@ -94,10 +90,10 @@ var _ = Describe("LocalStorage", func() { err = os.Symlink(realDir, linkDir) Expect(err).ToNot(HaveOccurred()) - u, err := url.Parse("file://" + linkDir) + u, err := storage.LocalPathToURL(linkDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -110,10 +106,10 @@ var _ = Describe("LocalStorage", func() { // Use a non-existent path to trigger symlink resolution failure nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := url.Parse("file://" + nonExistentPath) + u, err := storage.LocalPathToURL(nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -137,7 +133,9 @@ var _ = Describe("LocalStorage", func() { localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) - Expect(localStorage.u.Path).To(Equal("C:/music")) + // newLocalStorage re-joins the drive letter (u.Host) with u.Path via + // filepath.Join, which yields an OS-native (backslash) path on Windows. + Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music"))) }) }) @@ -159,10 +157,10 @@ var _ = Describe("LocalStorage", func() { It("falls back to the default extractor instead of crashing", func() { conf.Server.Scanner.Extractor = "nonexistent-extractor" - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) ls, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor)) @@ -171,16 +169,13 @@ var _ = Describe("LocalStorage", func() { }) Describe("localStorage.FS", func() { - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") - }) Context("with existing directory", func() { It("should return a localFS instance", func() { - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) Expect(musicFS).ToNot(BeNil()) @@ -193,10 +188,10 @@ var _ = Describe("LocalStorage", func() { Context("with non-existent directory", func() { It("should return an error", func() { nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := url.Parse("file://" + nonExistentPath) + u, err := storage.LocalPathToURL(nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) _, err = storage.FS() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(nonExistentPath)) @@ -204,11 +199,82 @@ var _ = Describe("LocalStorage", func() { }) }) + Describe("localFS.ResolveSymlink", func() { + var musicFS storage.MusicFS + + BeforeEach(func() { + if runtime.GOOS == "windows" { + Skip("symlink semantics") + } + u, err := storage.LocalPathToURL(tempDir) + Expect(err).ToNot(HaveOccurred()) + musicFS, err = newLocalStorage(u).FS() + Expect(err).ToNot(HaveOccurred()) + }) + + It("implements storage.SymlinkResolverFS", func() { + _, ok := musicFS.(storage.SymlinkResolverFS) + Expect(ok).To(BeTrue()) + }) + + It("resolves a chain that leaves the library folder to its final target", func() { + outside, err := os.MkdirTemp("", "navidrome-symlink-outside-") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(outside) }) + + target := filepath.Join(outside, "final.txt") + Expect(os.WriteFile(target, []byte("data"), 0600)).To(Succeed()) + mid := filepath.Join(outside, "mid.wav") + Expect(os.Symlink(target, mid)).To(Succeed()) + Expect(os.Symlink(mid, filepath.Join(tempDir, "link.wav"))).To(Succeed()) + + resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("link.wav") + Expect(err).ToNot(HaveOccurred()) + expected, err := filepath.EvalSymlinks(target) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(expected)) + }) + + It("resolves entries in subfolders (slash-separated fs paths)", func() { + Expect(os.MkdirAll(filepath.Join(tempDir, "sub"), 0755)).To(Succeed()) + target := filepath.Join(tempDir, "real.mp3") + Expect(os.WriteFile(target, []byte("audio"), 0600)).To(Succeed()) + Expect(os.Symlink(target, filepath.Join(tempDir, "sub", "link.mp3"))).To(Succeed()) + + resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("sub/link.mp3") + Expect(err).ToNot(HaveOccurred()) + expected, err := filepath.EvalSymlinks(target) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved).To(Equal(expected)) + }) + + It("returns an error for a broken symlink", func() { + Expect(os.Symlink(filepath.Join(tempDir, "missing.mp3"), filepath.Join(tempDir, "broken.mp3"))).To(Succeed()) + + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("broken.mp3") + Expect(err).To(HaveOccurred()) + }) + + It("rejects names that are not valid fs paths", func() { + for _, name := range []string{"../outside.mp3", "/etc/hosts", "sub/../../outside.mp3", ""} { + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink(name) + Expect(err).To(MatchError(fs.ErrInvalid), name) + } + }) + + It("returns an error for a symlink loop", func() { + Expect(os.Symlink(filepath.Join(tempDir, "loop2.mp3"), filepath.Join(tempDir, "loop1.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(tempDir, "loop1.mp3"), filepath.Join(tempDir, "loop2.mp3"))).To(Succeed()) + + _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("loop1.mp3") + Expect(err).To(HaveOccurred()) + }) + }) + Describe("localFS.ReadTags", func() { var testFile string BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // Create a test file testFile = filepath.Join(tempDir, "test.mp3") err := os.WriteFile(testFile, []byte("test data"), 0600) @@ -235,9 +301,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = expectedInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -259,9 +325,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = incompleteInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -288,9 +354,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["non-existent.mp3"] = incompleteInfo - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -303,9 +369,9 @@ var _ = Describe("LocalStorage", func() { It("should return the extractor error", func() { testExtractor.err = &extractorError{message: "extractor failed"} - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -334,9 +400,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = info1 testExtractor.results["test2.mp3"] = info2 - u, err := url.Parse("file://" + tempDir) + u, err := storage.LocalPathToURL(tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(*u) + storage := newLocalStorage(u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -390,9 +456,8 @@ var _ = Describe("LocalStorage", func() { Describe("Storage registration", func() { It("should register localStorage for file scheme", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // This tests the init() function indirectly - storage, err := storage.For("file://" + tempDir) + storage, err := storage.For(tempDir) Expect(err).ToNot(HaveOccurred()) Expect(storage).To(BeAssignableToTypeOf(&localStorage{})) }) diff --git a/core/storage/storage.go b/core/storage/storage.go index b9fceb1fd..242965c99 100644 --- a/core/storage/storage.go +++ b/core/storage/storage.go @@ -25,6 +25,27 @@ func Register(schema string, c constructor) { registry[schema] = c } +// LocalPathToURL converts a bare OS filesystem path into an absolute file:// URL, +// applying the same slash-normalisation and per-component escaping the scanner +// relies on. It is the single source of truth for how a local path becomes a +// storage URL, shared by For and by storage tests. +func LocalPathToURL(osPath string) (url.URL, error) { + abs, _ := filepath.Abs(osPath) + abs = filepath.ToSlash(abs) + + // Properly escape each path component using URL standards + pathParts := strings.Split(abs, "/") + escapedParts := slice.Map(pathParts, func(s string) string { + return url.PathEscape(s) + }) + + u, err := url.Parse(LocalSchemaID + "://" + strings.Join(escapedParts, "/")) + if err != nil { + return url.URL{}, err + } + return *u, nil +} + // For returns a Storage implementation for the given URI. // It uses the schema part of the URI to find the correct registered // Storage constructor. @@ -34,24 +55,22 @@ func For(uri string) (Storage, error) { defer lock.RUnlock() parts := strings.Split(uri, "://") + var u *url.URL // Paths without schema are treated as file:// and use the default LocalStorage implementation if len(parts) < 2 { - uri, _ = filepath.Abs(uri) - uri = filepath.ToSlash(uri) - - // Properly escape each path component using URL standards - pathParts := strings.Split(uri, "/") - escapedParts := slice.Map(pathParts, func(s string) string { - return url.PathEscape(s) - }) - - uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/") + parsed, err := LocalPathToURL(uri) + if err != nil { + return nil, err + } + u = &parsed + } else { + parsed, err := url.Parse(uri) + if err != nil { + return nil, err + } + u = parsed } - u, err := url.Parse(uri) - if err != nil { - return nil, err - } c, ok := registry[u.Scheme] if !ok { return nil, errors.New("schema '" + u.Scheme + "' not registered") diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 32fbac413..336b5a7a9 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -4,6 +4,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "testing" "github.com/navidrome/navidrome/tests" @@ -83,6 +84,43 @@ var _ = Describe("Storage", func() { Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"), ) }) + + Describe("LocalPathToURL", func() { + It("builds a file:// URL from an absolute path", func() { + u, err := LocalPathToURL("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + Expect(u.Scheme).To(Equal("file")) + Expect(u.Path).To(Equal("/tmp/music")) + }) + + It("escapes special characters and decodes them back in Path", func() { + u, err := LocalPathToURL("/tmp/Song #1 & More?.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(u.Path).To(Equal("/tmp/Song #1 & More?.mp3")) + }) + + It("produces the same url.URL that For uses for a bare path", func() { + registry = map[string]constructor{} + Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} }) + s, err := For("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + direct, err := LocalPathToURL("/tmp/music") + Expect(err).ToNot(HaveOccurred()) + Expect(s.(*fakeLocalStorage).u).To(Equal(direct)) + }) + + // On Windows, library paths are drive-lettered (e.g. C:\Music). This + // exercises the real conversion to confirm a drive-letter path yields a + // usable file:// URL on the actual platform (no-op on Unix). + It("handles a Windows drive-letter path", func() { + if runtime.GOOS != "windows" { + Skip("Windows-specific path handling") + } + u, err := LocalPathToURL(`C:\Music`) + Expect(err).ToNot(HaveOccurred()) + Expect(u.Scheme).To(Equal("file")) + }) + }) }) type fakeLocalStorage struct { diff --git a/core/stream/codec.go b/core/stream/codec.go index 28bff75c4..56d163324 100644 --- a/core/stream/codec.go +++ b/core/stream/codec.go @@ -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 diff --git a/core/stream/decider.go b/core/stream/decider.go index d6e48497c..3c6b01e05 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" ) const fallbackBitrate = 256 // kbps @@ -142,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta sd.Codec = mf.AudioCodec() sd.Bitrate = mf.BitRate sd.SampleRate = mf.SampleRate - sd.BitDepth = mf.BitDepth + sd.BitDepth = gg.V(mf.BitDepth) sd.Channels = mf.Channels } sd.IsLossless = isLosslessFormat(sd.Codec) @@ -268,7 +269,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai Codec: strings.ToLower(profile.AudioCodec), SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), Channels: src.Channels, - BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), + BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless), IsLossless: targetIsLossless, } if ts.Codec == "" { diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index f74953258..577207636 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,7 +24,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile { Codec: mf.AudioCodec(), BitRate: mf.BitRate, SampleRate: mf.SampleRate, - BitDepth: mf.BitDepth, + BitDepth: gg.V(mf.BitDepth), Channels: mf.Channels, } data, _ := json.Marshal(probe) @@ -243,7 +244,7 @@ var _ = Describe("Decider", func() { Context("Transcoding", func() { It("selects transcoding when direct play isn't possible", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, // kbps DirectPlayProfiles: []DirectPlayProfile{ @@ -278,7 +279,7 @@ var _ = Describe("Decider", func() { }) It("uses default bitrate when client doesn't specify", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "mp3", Protocol: ProtocolHTTP}, @@ -331,7 +332,7 @@ var _ = Describe("Decider", func() { }) It("selects first valid transcoding profile in order", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, DirectPlayProfiles: []DirectPlayProfile{ @@ -351,7 +352,7 @@ var _ = Describe("Decider", func() { Context("Lossless to lossless transcoding", func() { It("allows lossless to lossless when samplerate needs downsampling", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)}) ci := &ClientInfo{ MaxAudioBitrate: 1000, DirectPlayProfiles: []DirectPlayProfile{ @@ -369,7 +370,7 @@ var _ = Describe("Decider", func() { It("sets IsLossless=true on transcoded stream when target is lossless", func() { // Transcoding to mp3 (lossy) should result in IsLossless=false. - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -526,7 +527,7 @@ var _ = Describe("Decider", func() { }) It("rejects direct play due to samplerate limitation", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, @@ -573,7 +574,7 @@ var _ = Describe("Decider", func() { }) It("applies channel limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -596,7 +597,7 @@ var _ = Describe("Decider", func() { }) It("applies samplerate limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -619,7 +620,7 @@ var _ = Describe("Decider", func() { }) It("applies bitdepth limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -642,7 +643,7 @@ var _ = Describe("Decider", func() { }) It("preserves source bit depth when no limitation applies", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -655,8 +656,46 @@ var _ = Describe("Decider", func() { Expect(decision.TargetBitDepth).To(Equal(24)) }) + It("omits bit depth when transcoding to a lossy format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + Expect(decision.TargetBitDepth).To(BeZero()) + }) + + It("ignores audioBitdepth limitation when transcoding to a lossy format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "opus", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + }) + It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -680,7 +719,7 @@ var _ = Describe("Decider", func() { Context("DSD sample rate conversion", func() { It("converts DSD sample rate to PCM-equivalent in decision", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -694,13 +733,13 @@ var _ = Describe("Decider", func() { // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) Expect(decision.TargetSampleRate).To(Equal(48000)) - // DSD 1-bit → 24-bit PCM - Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) - Expect(decision.TargetBitDepth).To(Equal(24)) + // MP3 is lossy: no bit depth on the transcoded stream + Expect(decision.TranscodeStream.BitDepth).To(BeZero()) + Expect(decision.TargetBitDepth).To(BeZero()) }) It("converts DSD sample rate for FLAC target without codec limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -719,7 +758,7 @@ var _ = Describe("Decider", func() { }) It("applies codec profile limit to DSD-converted FLAC sample rate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -746,7 +785,7 @@ var _ = Describe("Decider", func() { }) It("applies audioBitdepth limitation to DSD-converted bit depth", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -775,7 +814,7 @@ var _ = Describe("Decider", func() { // Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels. // The decider must clamp to the codec's hard limit even when no // transcoding profile MaxAudioChannels is configured. - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -791,7 +830,7 @@ var _ = Describe("Decider", func() { }) It("honors a stricter profile MaxAudioChannels over the codec clamp", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -806,7 +845,7 @@ var _ = Describe("Decider", func() { }) It("applies the codec clamp when the profile limit is looser", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -821,7 +860,7 @@ var _ = Describe("Decider", func() { }) It("passes channels through unchanged for codecs with no hard limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -840,7 +879,7 @@ var _ = Describe("Decider", func() { Context("Probe-based lossless detection", func() { It("uses probe codec name for lossless detection", func() { // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv" - mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)} probe := ffmpeg.AudioProbeResult{ Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2, } @@ -884,7 +923,7 @@ var _ = Describe("Decider", func() { Context("Opus fixed sample rate", func() { It("sets Opus output to 48000Hz regardless of input", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -901,7 +940,7 @@ var _ = Describe("Decider", func() { }) It("sets Opus output to 48000Hz even for 96kHz input", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -917,7 +956,7 @@ var _ = Describe("Decider", func() { Context("Container vs format separation", func() { It("preserves mp4 container when falling back to aac format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -935,7 +974,7 @@ var _ = Describe("Decider", func() { }) It("uses container as format when container matches transcoding config", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -952,7 +991,7 @@ var _ = Describe("Decider", func() { Context("MP3 max sample rate", func() { It("caps sample rate at 48000 for MP3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -966,7 +1005,7 @@ var _ = Describe("Decider", func() { }) It("preserves sample rate at 44100 for MP3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -982,7 +1021,7 @@ var _ = Describe("Decider", func() { Context("AAC max sample rate", func() { It("caps sample rate at 96000 for AAC", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -1025,7 +1064,7 @@ var _ = Describe("Decider", func() { Context("Source stream details", func() { It("populates source stream correctly with kbps bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, @@ -1058,7 +1097,7 @@ var _ = Describe("Decider", func() { }) It("ignores player MaxBitRate in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ Name: "TestClient", DirectPlayProfiles: []DirectPlayProfile{ @@ -1074,7 +1113,7 @@ var _ = Describe("Decider", func() { Context("Format-aware default bitrate", func() { It("uses opus default bitrate from DB", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, @@ -1087,7 +1126,7 @@ var _ = Describe("Decider", func() { }) It("uses aac default bitrate from DB", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 9dd6179a0..652e42eba 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -12,7 +12,7 @@ import ( // buildLegacyClientInfo translates legacy Subsonic stream/download parameters // into a ClientInfo for use with MakeDecision. -func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} // Determine target format for transcoding @@ -22,6 +22,10 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int targetFormat = reqFormat case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": targetFormat = conf.Server.DefaultDownsamplingFormat + case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + // Server-side player MaxBitRate alone forces downsampling, even when the + // client sent no format/bitrate params (issue #5583, legacy /stream path). + targetFormat = conf.Server.DefaultDownsamplingFormat } if targetFormat != "" { @@ -63,15 +67,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile return req } - clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + playerMaxBitRate := 0 + if player, ok := request.PlayerFrom(ctx); ok { + playerMaxBitRate = player.MaxBitRate + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) // Apply server-side player transcoding override before making the decision if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { - modified := *clientInfo - modified.MaxAudioBitrate = player.MaxBitRate + } else if player, ok := request.PlayerFrom(ctx); ok { + modified := *clientInfo + if modified.CapBitrate(player.MaxBitRate) { clientInfo = &modified log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) } diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index ce7b38650..bc8405976 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -21,7 +21,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("sets transcoding profile for explicit format without bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 0) + ci := buildLegacyClientInfo(mf, "mp3", 0, 0) Expect(ci.Name).To(Equal("legacy")) Expect(ci.TranscodingProfiles).To(HaveLen(1)) @@ -34,7 +34,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("does not add direct play profile when explicit format differs from source (no bitrate)", func() { - ci := buildLegacyClientInfo(mf, "opus", 0) + ci := buildLegacyClientInfo(mf, "opus", 0, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) @@ -42,7 +42,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("adds direct play profile when explicit format matches source format", func() { - ci := buildLegacyClientInfo(mf, "flac", 0) + ci := buildLegacyClientInfo(mf, "flac", 0, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac")) @@ -52,7 +52,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("sets transcoding profile and bitrate for explicit format with bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 192) + ci := buildLegacyClientInfo(mf, "mp3", 192, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) @@ -63,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("returns direct play profile when no format and no bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 0) + ci := buildLegacyClientInfo(mf, "", 0, 0) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -77,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - ci := buildLegacyClientInfo(mf, "", 128) + ci := buildLegacyClientInfo(mf, "", 128, 0) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) @@ -91,7 +91,7 @@ var _ = Describe("buildLegacyClientInfo", func() { }) It("returns direct play when bitrate >= source bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 960) + ci := buildLegacyClientInfo(mf, "", 960, 0) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -100,6 +100,51 @@ var _ = Describe("buildLegacyClientInfo", func() { Expect(ci.TranscodingProfiles).To(BeEmpty()) Expect(ci.MaxAudioBitrate).To(BeZero()) }) + + It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 0, 256) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + }) + + It("does not downsample when player MaxBitRate is >= source bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 0, 960) + + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + }) + + It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + ci := buildLegacyClientInfo(mf, "", 0, 256) + + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) + }) + + It("prefers explicit request format over player cap", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "mp3", 0, 256) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + }) }) var _ = Describe("ResolveRequest", func() { @@ -138,7 +183,7 @@ var _ = Describe("ResolveRequest", func() { }) It("transcodes to requested format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "opus", 0, 0) @@ -147,7 +192,7 @@ var _ = Describe("ResolveRequest", func() { }) It("transcodes to requested format with bitrate limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0) @@ -169,7 +214,7 @@ var _ = Describe("ResolveRequest", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "", 128, 0) @@ -179,7 +224,7 @@ var _ = Describe("ResolveRequest", func() { }) It("passes offset through", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "opus", 128, 30) @@ -259,7 +304,7 @@ var _ = Describe("ResolveRequest", func() { Context("Player MaxBitRate cap", func() { It("applies player MaxBitRate cap when client has no limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) decider := svc.(*deciderService) @@ -270,7 +315,7 @@ var _ = Describe("ResolveRequest", func() { }) It("uses client limit when it is more restrictive than player MaxBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) decider := svc.(*deciderService) @@ -289,6 +334,33 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Format).To(Equal("raw")) }) + + It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("opus")) + Expect(req.BitRate).To(Equal(256)) + }) + + It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "" + + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) }) Context("fallback for unknown format", func() { @@ -332,7 +404,7 @@ var _ = Describe("ResolveRequest", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DefaultDownsamplingFormat = "opus" - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0) diff --git a/core/stream/limiter.go b/core/stream/limiter.go new file mode 100644 index 000000000..622fe21cc --- /dev/null +++ b/core/stream/limiter.go @@ -0,0 +1,135 @@ +package stream + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" +) + +// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the +// configured concurrency cap has been reached. Callers should translate this +// into an HTTP 429 response so well-behaved clients back off and retry. +var ErrTooManyTranscodes = errors.New("too many concurrent transcodes") + +// RetryAfterSeconds is the value returned in the HTTP Retry-After header when +// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well +// within this window, so retrying after this delay typically succeeds. +const RetryAfterSeconds = 5 + +// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces +// both a global cap (to protect the host from process exhaustion) and an optional +// per-user cap (to keep one client from starving the others). Acquire never +// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately. +type TranscodeLimiter interface { + // Acquire reserves a slot for the given user. On success it returns a release + // function that must be called exactly once when the transcode is done. + // Calling release more than once is safe and idempotent. + Acquire(ctx context.Context, user string) (release func(), err error) + + // Enabled reports whether the limiter actually enforces any cap. Callers + // can use it to decide whether to bind ffmpeg's lifetime to the request + // context so disconnects free slots quickly, rather than letting the + // process drain to completion in the background. + Enabled() bool +} + +// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is +// independent: a value of zero or less disables that cap. When both caps are +// disabled the limiter is a no-op. +func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter { + if maxConcurrent <= 0 && maxPerUser <= 0 { + return noopLimiter{} + } + l := &transcodeLimiter{maxPerUser: maxPerUser} + if maxConcurrent > 0 { + l.global = make(chan struct{}, maxConcurrent) + } + if maxPerUser > 0 { + l.perUser = make(map[string]int) + } + return l +} + +// releasingReadCloser wraps an io.ReadCloser so that closing it also releases +// the limiter slot exactly once. release must be the function returned by +// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too. +type releasingReadCloser struct { + io.ReadCloser + release func() +} + +func (r *releasingReadCloser) Close() error { + err := r.ReadCloser.Close() + r.release() + return err +} + +type noopLimiter struct{} + +func (noopLimiter) Acquire(context.Context, string) (func(), error) { + return func() {}, nil +} + +func (noopLimiter) Enabled() bool { return false } + +type transcodeLimiter struct { + maxPerUser int + global chan struct{} + + mu sync.Mutex + perUser map[string]int +} + +func (*transcodeLimiter) Enabled() bool { return true } + +func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) { + // Reserve a per-user slot first so a noisy user can't burn through + // global slots only to be rejected later. An empty user key means + // "anonymous" (e.g. public share viewers); we skip the per-user cap + // entirely so unrelated anonymous clients do not share a bucket. + perUserActive := l.maxPerUser > 0 && user != "" + if perUserActive { + l.mu.Lock() + if l.perUser[user] >= l.maxPerUser { + l.mu.Unlock() + return nil, ErrTooManyTranscodes + } + l.perUser[user]++ + l.mu.Unlock() + } + + if l.global != nil { + select { + case l.global <- struct{}{}: + default: + if perUserActive { + l.releasePerUser(user) + } + return nil, ErrTooManyTranscodes + } + } + + var released atomic.Bool + return func() { + if !released.CompareAndSwap(false, true) { + return + } + if l.global != nil { + <-l.global + } + if perUserActive { + l.releasePerUser(user) + } + }, nil +} + +func (l *transcodeLimiter) releasePerUser(user string) { + l.mu.Lock() + defer l.mu.Unlock() + l.perUser[user]-- + if l.perUser[user] <= 0 { + delete(l.perUser, user) + } +} diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go new file mode 100644 index 000000000..d278d47c8 --- /dev/null +++ b/core/stream/limiter_test.go @@ -0,0 +1,186 @@ +package stream_test + +import ( + "context" + "errors" + "sync" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TranscodeLimiter", func() { + ctx := log.NewContext(context.TODO()) + + Describe("Disabled (both caps <= 0)", func() { + It("never blocks and never returns ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(0, 0) + for range 100 { + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + Expect(rel).ToNot(BeNil()) + } + }) + }) + + Describe("Per-user cap only (no global cap)", func() { + It("still enforces the per-user limit when MaxConcurrent is disabled", func() { + lim := stream.NewTranscodeLimiter(0, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // Other users have their own buckets. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + }) + + Describe("Global cap", func() { + It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(2, 0) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "carol") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + _, err = lim.Acquire(ctx, "carol") + Expect(err).ToNot(HaveOccurred()) + + rel2() + }) + + It("releases a slot only once even if release is called multiple times", func() { + lim := stream.NewTranscodeLimiter(1, 0) + + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel() + rel() + rel() + + // After releases, exactly one slot should be available. + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) + + Describe("Per-user cap", func() { + It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() { + lim := stream.NewTranscodeLimiter(10, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // A different user is unaffected. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + + It("skips the per-user cap for anonymous users (empty key)", func() { + // Anonymous requests (e.g. public share viewers) deliberately + // bypass the per-user cap so unrelated anonymous clients are not + // collapsed into a single shared bucket. The global cap remains + // the only ceiling on anonymous traffic. + lim := stream.NewTranscodeLimiter(10, 1) + + rels := make([]func(), 0, 5) + for range 5 { + rel, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rels = append(rels, rel) + } + for _, rel := range rels { + rel() + } + }) + + It("still applies the global cap to anonymous users", func() { + lim := stream.NewTranscodeLimiter(2, 1) + + rel1, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + rel2() + }) + }) + + Describe("Concurrent safety", func() { + It("survives parallel Acquire/release with consistent counts", func() { + lim := stream.NewTranscodeLimiter(5, 0) + + var wg sync.WaitGroup + var acquired int64 + var rejected int64 + var mu sync.Mutex + + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + rel, err := lim.Acquire(ctx, "alice") + mu.Lock() + if err == nil { + acquired++ + mu.Unlock() + rel() + } else { + rejected++ + mu.Unlock() + } + _ = i + }(i) + } + wg.Wait() + + Expect(acquired + rejected).To(Equal(int64(50))) + // After all releases, all 5 slots should be free again. + for range 5 { + _, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + } + _, err := lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) +}) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index de03b4d2f..b09d9bab8 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -2,6 +2,7 @@ package stream import ( "context" + "errors" "fmt" "io" "mime" @@ -28,13 +29,19 @@ type MediaStreamer interface { type TranscodingCache cache.FileCache func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer { - return &mediaStreamer{ds: ds, transcoder: t, cache: cache} + return &mediaStreamer{ + ds: ds, + transcoder: t, + cache: cache, + limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser), + } } type mediaStreamer struct { ds model.DataStore transcoder ffmpeg.FFmpeg cache cache.FileCache + limiter TranscodeLimiter } type streamJob struct { @@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } r, err := ms.cache.Get(ctx, job) if err != nil { - log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + // Rate-limit rejections are already logged at warn level by the + // producer; treating them as cache failures here would both + // double-log and mask actual cache problems. + if !errors.Is(err, ErrTooManyTranscodes) { + log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + } return nil, err } cached = r.Cached @@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache { return nil, os.ErrInvalid } - // Choose the appropriate context based on EnableTranscodingCancellation configuration. - // This is where we decide whether transcoding processes should be cancellable or not. + release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx)) + if err != nil { + log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached", + "id", job.mf.ID, "user", userName(ctx), + "maxConcurrent", conf.Server.Transcoding.MaxConcurrent, + "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser) + return nil, err + } + + // Choose the context that drives the ffmpeg process. + // + // When the limiter is enabled, force the request context so a + // client disconnect cancels ffmpeg and frees the slot promptly. + // Otherwise a client could open many transcodes, disconnect + // immediately, and still leave the configured cap's worth of + // ffmpeg processes draining in the background — which is exactly + // the DoS the limiter is meant to prevent. + // + // When the limiter is disabled, preserve the legacy behavior + // governed by Transcoding.EnableCancellation so unchanged configs + // keep their previous observable behavior. var transcodingCtx context.Context - if conf.Server.EnableTranscodingCancellation { - // Use the request context directly, allowing cancellation when client disconnects + if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation { transcodingCtx = ctx } else { - // Use background context with request values preserved. - // This prevents cancellation but maintains request metadata (user, client, etc.) transcodingCtx = request.AddValues(context.Background(), ctx) } @@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache { Offset: job.offset, }) if err != nil { + release() log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid } - return out, nil + // Tie the slot to the ffmpeg process: copyAndClose calls Close + // on this reader after io.Copy returns, which is exactly when + // ffmpeg has exited (either EOF or context cancellation). + return &releasingReadCloser{ReadCloser: out, release: release}, nil }) } @@ -255,3 +287,16 @@ func userName(ctx context.Context) string { return user.UserName } } + +// limiterKey returns the per-user bucket key used by the transcode limiter. +// For anonymous requests (e.g. public shares) it returns the empty string, +// which signals the limiter to skip the per-user cap entirely — otherwise +// every anonymous viewer of a public share would collide on the same key +// and starve each other within MaxConcurrentPerUser slots. The global cap +// still applies and remains the protection against runaway anonymous load. +func limiterKey(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); ok { + return user.UserName + } + return "" +} diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bc21e239..f5ca16d3f 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -2,6 +2,7 @@ package stream_test import ( "context" + "errors" "io" "os" @@ -10,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -23,7 +25,8 @@ var _ = Describe("MediaStreamer", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches") + cacheDir, _ := os.MkdirTemp("", "file_caches") + conf.Server.CacheFolder = conf.NewDir(cacheDir) conf.Server.TranscodingCacheSize = "100MB" ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -34,7 +37,7 @@ var _ = Describe("MediaStreamer", func() { streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { - _ = os.RemoveAll(conf.Server.CacheFolder) + _ = os.RemoveAll(conf.Server.CacheFolder.String()) }) Context("NewStream", func() { @@ -60,6 +63,70 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) + It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + // Use an ffmpeg whose Read blocks indefinitely so the cache's + // background copy can't drain the source and release the slot — + // keeping the single transcode slot pinned for this test. + pr, pw := io.Pipe() + DeferCleanup(func() { _ = pw.Close() }) + blockingFFmpeg := tests.NewMockFFmpeg("") + blockingFFmpeg.Reader = pr + + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Different cache key so it doesn't dedupe with the first request. + _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + + It("releases the slot once the stream is closed", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + _, _ = io.ReadAll(s1) + _ = s1.Close() + Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) + + // Slot should now be free for a different transcode. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + + It("does not consume a slot for raw streams", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) + + userCtx := request.WithUsername(ctx, "alice") + // First, saturate the single transcode slot. + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Raw stream must still succeed. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + It("returns a seekable stream if the file is complete in the cache", func() { s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) diff --git a/core/stream/token.go b/core/stream/token.go index 24a154b54..21a26ca93 100644 --- a/core/stream/token.go +++ b/core/stream/token.go @@ -92,17 +92,10 @@ func paramsFromToken(token jwt.Token) (*params, error) { return &p, nil } -// getIntClaim extracts an int claim from a JWT token, handling the case where -// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). +// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a +// parsed token are always deserialized as float64, regardless of the type used +// when encoding. func getIntClaim(token jwt.Token, key string) int { - var v int - if err := token.Get(key, &v); err == nil { - return v - } - var v64 int64 - if err := token.Get(key, &v64); err == nil { - return int(v64) - } var f float64 if err := token.Get(key, &f); err == nil { return int(f) diff --git a/core/stream/types.go b/core/stream/types.go index bd8ce292c..19474dd91 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -40,6 +40,50 @@ type ClientInfo struct { CodecProfiles []CodecProfile } +// CapBitrate lowers the client's declared audio bitrate limits to maxKbps, +// never raising them. A zero limit means "unlimited" and is set to maxKbps. +// Returns true if anything changed. No-op when maxKbps <= 0. +func (ci *ClientInfo) CapBitrate(maxKbps int) bool { + if maxKbps <= 0 { + return false + } + changed := false + if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate { + ci.MaxAudioBitrate = maxKbps + changed = true + } + if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate { + ci.MaxTranscodingAudioBitrate = maxKbps + changed = true + } + return changed +} + +// ForceFormat narrows the client to transcoding to targetFormat and suppresses +// direct play, but only if the client already declares a profile for that +// format. All matching profiles are kept so negotiation can still pick among +// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or +// unsupported. +func (ci *ClientInfo) ForceFormat(targetFormat string) bool { + if targetFormat == "" { + return false + } + var matched []Profile + for i := range ci.TranscodingProfiles { + // matchesContainer is alias-aware, so a forced "oga" (legacy Opus + // target_format) still matches a resolved "opus" profile. + if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) { + matched = append(matched, ci.TranscodingProfiles[i]) + } + } + if len(matched) == 0 { + return false + } + ci.TranscodingProfiles = matched + ci.DirectPlayProfiles = nil + return true +} + // DirectPlayProfile describes a format the client can play directly type DirectPlayProfile struct { Containers []string diff --git a/core/stream/types_test.go b/core/stream/types_test.go new file mode 100644 index 000000000..eff408362 --- /dev/null +++ b/core/stream/types_test.go @@ -0,0 +1,133 @@ +package stream + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ClientInfo", func() { + Describe("CapBitrate", func() { + It("is a no-op when maxKbps is zero", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} + Expect(ci.CapBitrate(0)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(320)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("is a no-op when maxKbps is negative", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} + Expect(ci.CapBitrate(-1)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(320)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("sets both limits when both are zero (unlimited)", func() { + ci := &ClientInfo{} + Expect(ci.CapBitrate(256)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(256)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(256)) + }) + + It("lowers limits higher than maxKbps", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 500} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("does not raise limits lower than maxKbps", func() { + ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 96} + Expect(ci.CapBitrate(320)).To(BeFalse()) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(96)) + }) + + It("reports changed when only one limit is lowered", func() { + ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 128} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + + It("caps only the zero (unlimited) limit", func() { + ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 0} + Expect(ci.CapBitrate(192)).To(BeTrue()) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + }) + + Describe("ForceFormat", func() { + It("restricts to the forced format and clears direct play when supported", func() { + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("opus") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(ci.DirectPlayProfiles).To(BeEmpty()) + }) + + It("matches a container-only forced format (mp3)", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("mp3") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + }) + + It("matches the forced format against codec aliases (oga/opus)", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + // Legacy DBs may store the Opus transcoding as target_format "oga". + ok := ci.ForceFormat("oga") + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + }) + + It("is a no-op when the forced format is not supported by the client", func() { + original := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: original, + } + ok := ci.ForceFormat("opus") + Expect(ok).To(BeFalse()) + Expect(ci.TranscodingProfiles).To(Equal(original)) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + }) + + It("is a no-op for an empty target format", func() { + ci := &ClientInfo{TranscodingProfiles: []Profile{{Container: "mp3", AudioCodec: "mp3"}}} + Expect(ci.ForceFormat("")).To(BeFalse()) + }) + + It("keeps all matching profiles when multiple resolve to the forced format", func() { + first := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP} + second := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP, MaxAudioChannels: 2} + other := Profile{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP} + ci := &ClientInfo{TranscodingProfiles: []Profile{first, other, second}} + + ok := ci.ForceFormat("opus") + + Expect(ok).To(BeTrue()) + Expect(ci.TranscodingProfiles).To(ConsistOf(first, second)) + }) + }) +}) diff --git a/db/backup.go b/db/backup.go index a34255d7e..806bef8e2 100644 --- a/db/backup.go +++ b/db/backup.go @@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05" func backupPath(t time.Time) string { return filepath.Join( - conf.Server.Backup.Path, + conf.Server.Backup.Path.MustPath(), fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), ) } @@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error { } func Prune(ctx context.Context) (int, error) { - files, err := os.ReadDir(conf.Server.Backup.Path) + backupDir, err := conf.Server.Backup.Path.Path() + if err != nil { + return 0, fmt.Errorf("backup directory not available: %w", err) + } + files, err := os.ReadDir(backupDir) if err != nil { return 0, fmt.Errorf("unable to read database backup entries: %w", err) } diff --git a/db/backup_test.go b/db/backup_test.go index aec43446d..5e8f877e6 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -60,7 +60,7 @@ var _ = Describe("database backups", func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) @@ -118,7 +118,7 @@ var _ = Describe("database backups", func() { BeforeEach(func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) diff --git a/db/db.go b/db/db.go index 0945d1a00..3f3f61d71 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "fmt" "runtime" + "time" "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" @@ -38,6 +39,8 @@ func Db() *sql.DB { if Path == ":memory:" { Path = "file::memory:?cache=shared&_foreign_keys=on" conf.Server.DbPath = Path + } else { + conf.Server.DataFolder.MustPath() } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) @@ -45,13 +48,6 @@ func Db() *sql.DB { 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 nil - } - } return db }) } @@ -60,9 +56,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 { @@ -101,11 +94,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) } } @@ -114,37 +107,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...)) } diff --git a/db/export_test.go b/db/export_test.go index 734a4462f..02b88cd66 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -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 ) diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go index 2fae4f57d..250fb00a5 100644 --- a/db/migrations/20200130083147_create_schema.go +++ b/db/migrations/20200130083147_create_schema.go @@ -12,9 +12,9 @@ func init() { goose.AddMigrationContext(Up20200130083147, Down20200130083147) } -func Up20200130083147(_ context.Context, tx *sql.Tx) error { +func Up20200130083147(ctx context.Context, tx *sql.Tx) error { log.Info("Creating DB Schema") - _, err := tx.Exec(` + _, err := tx.ExecContext(ctx, ` create table if not exists album ( id varchar(255) not null @@ -179,6 +179,6 @@ create table if not exists user return err } -func Down20200130083147(_ context.Context, tx *sql.Tx) error { +func Down20200130083147(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go index 471dc8002..bf7d9d5f7 100644 --- a/db/migrations/20200131183653_standardize_item_type.go +++ b/db/migrations/20200131183653_standardize_item_type.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200131183653, Down20200131183653) } -func Up20200131183653(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200131183653(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table search_dg_tmp ( id varchar(255) not null @@ -37,8 +37,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile'; return err } -func Down20200131183653(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Down20200131183653(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table search_dg_tmp ( id varchar(255) not null diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migrations/20200208222418_add_defaults_to_annotations.go index d058b02c3..6807c8ad2 100644 --- a/db/migrations/20200208222418_add_defaults_to_annotations.go +++ b/db/migrations/20200208222418_add_defaults_to_annotations.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200208222418, Down20200208222418) } -func Up20200208222418(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200208222418(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` update annotation set play_count = 0 where play_count is null; update annotation set rating = 0 where rating is null; create table annotation_dg_tmp @@ -51,6 +51,6 @@ create index annotation_starred return err } -func Down20200208222418(_ context.Context, tx *sql.Tx) error { +func Down20200208222418(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migrations/20200220143731_change_duration_to_float.go index 72b785ef8..ea5465ade 100644 --- a/db/migrations/20200220143731_change_duration_to_float.go +++ b/db/migrations/20200220143731_change_duration_to_float.go @@ -11,9 +11,9 @@ func init() { goose.AddMigrationContext(Up20200220143731, Down20200220143731) } -func Up20200220143731(_ context.Context, tx *sql.Tx) error { - notice(tx, "This migration will force the next scan to be a full rescan!") - _, err := tx.Exec(` +func Up20200220143731(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "This migration will force the next scan to be a full rescan!") + _, err := tx.ExecContext(ctx, ` create table media_file_dg_tmp ( id varchar(255) not null @@ -125,6 +125,6 @@ update media_file set updated_at = '0001-01-01'; return err } -func Down20200220143731(_ context.Context, tx *sql.Tx) error { +func Down20200220143731(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go index 373e0a475..73436c890 100644 --- a/db/migrations/20200310171621_enable_search_by_albumartist.go +++ b/db/migrations/20200310171621_enable_search_by_albumartist.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200310171621, Down20200310171621) } -func Up20200310171621(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to enable search by Album Artist!") - return forceFullRescan(tx) +func Up20200310171621(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!") + return forceFullRescan(ctx, tx) } -func Down20200310171621(_ context.Context, tx *sql.Tx) error { +func Down20200310171621(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migrations/20200310181627_add_transcoding_and_player_tables.go index 3be91ac35..ef872c4ae 100644 --- a/db/migrations/20200310181627_add_transcoding_and_player_tables.go +++ b/db/migrations/20200310181627_add_transcoding_and_player_tables.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200310181627, Down20200310181627) } -func Up20200310181627(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200310181627(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table transcoding ( id varchar(255) not null primary key, @@ -45,8 +45,8 @@ create table player return err } -func Down20200310181627(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Down20200310181627(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` drop table transcoding; drop table player; `) diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migrations/20200319211049_merge_search_into_main_tables.go index f888cdd4c..a7a6ff0f9 100644 --- a/db/migrations/20200319211049_merge_search_into_main_tables.go +++ b/db/migrations/20200319211049_merge_search_into_main_tables.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200319211049, Down20200319211049) } -func Up20200319211049(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200319211049(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add full_text varchar(255) default ''; create index if not exists media_file_full_text @@ -33,10 +33,10 @@ drop table if exists search; if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200319211049(_ context.Context, tx *sql.Tx) error { +func Down20200319211049(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migrations/20200325185135_add_album_artist_id.go index f01f2c558..01537f886 100644 --- a/db/migrations/20200325185135_add_album_artist_id.go +++ b/db/migrations/20200325185135_add_album_artist_id.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200325185135, Down20200325185135) } -func Up20200325185135(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200325185135(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add album_artist_id varchar(255) default ''; create index album_artist_album_id @@ -26,10 +26,10 @@ create index media_file_artist_album_id if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200325185135(_ context.Context, tx *sql.Tx) error { +func Down20200325185135(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200326090707_fix_album_artists_importing.go b/db/migrations/20200326090707_fix_album_artists_importing.go index c42e8c327..17afe37fe 100644 --- a/db/migrations/20200326090707_fix_album_artists_importing.go +++ b/db/migrations/20200326090707_fix_album_artists_importing.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200326090707, Down20200326090707) } -func Up20200326090707(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) +func Up20200326090707(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200326090707(_ context.Context, tx *sql.Tx) error { +func Down20200326090707(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migrations/20200327193744_add_year_range_to_album.go index 66f2b23e8..d9b048e22 100644 --- a/db/migrations/20200327193744_add_year_range_to_album.go +++ b/db/migrations/20200327193744_add_year_range_to_album.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200327193744, Down20200327193744) } -func Up20200327193744(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200327193744(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table album_dg_tmp ( id varchar(255) not null @@ -72,10 +72,10 @@ create index album_max_year if err != nil { return err } - notice(tx, "A full rescan will be performed!") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed!") + return forceFullRescan(ctx, tx) } -func Down20200327193744(_ context.Context, tx *sql.Tx) error { +func Down20200327193744(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go index 6207b0a3d..8b8d8607e 100644 --- a/db/migrations/20200404214704_add_indexes.go +++ b/db/migrations/20200404214704_add_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200404214704, Down20200404214704) } -func Up20200404214704(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200404214704(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_year on media_file (year); @@ -25,6 +25,6 @@ create index if not exists media_file_track_number return err } -func Down20200404214704(_ context.Context, tx *sql.Tx) error { +func Down20200404214704(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200409002249_enable_search_by_tracks_artists.go b/db/migrations/20200409002249_enable_search_by_tracks_artists.go index 22006c8af..482341a89 100644 --- a/db/migrations/20200409002249_enable_search_by_tracks_artists.go +++ b/db/migrations/20200409002249_enable_search_by_tracks_artists.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200409002249, Down20200409002249) } -func Up20200409002249(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!") - return forceFullRescan(tx) +func Up20200409002249(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!") + return forceFullRescan(ctx, tx) } -func Down20200409002249(_ context.Context, tx *sql.Tx) error { +func Down20200409002249(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go index 266dc087d..4aa502b4b 100644 --- a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go +++ b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200411164603, Down20200411164603) } -func Up20200411164603(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200411164603(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add created_at datetime; alter table playlist @@ -23,6 +23,6 @@ update playlist return err } -func Down20200411164603(_ context.Context, tx *sql.Tx) error { +func Down20200411164603(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200418110522_reindex_to_fix_album_years.go b/db/migrations/20200418110522_reindex_to_fix_album_years.go index 22b024cea..54e03f4c6 100644 --- a/db/migrations/20200418110522_reindex_to_fix_album_years.go +++ b/db/migrations/20200418110522_reindex_to_fix_album_years.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200418110522, Down20200418110522) } -func Up20200418110522(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to fix search Albums by year") - return forceFullRescan(tx) +func Up20200418110522(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to fix search Albums by year") + return forceFullRescan(ctx, tx) } -func Down20200418110522(_ context.Context, tx *sql.Tx) error { +func Down20200418110522(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200419222708_reindex_to_change_full_text_search.go b/db/migrations/20200419222708_reindex_to_change_full_text_search.go index efeb1bb84..89e3ccee5 100644 --- a/db/migrations/20200419222708_reindex_to_change_full_text_search.go +++ b/db/migrations/20200419222708_reindex_to_change_full_text_search.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(Up20200419222708, Down20200419222708) } -func Up20200419222708(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(tx) +func Up20200419222708(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(ctx, tx) } -func Down20200419222708(_ context.Context, tx *sql.Tx) error { +func Down20200419222708(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go index 4097a9d60..a51bb2270 100644 --- a/db/migrations/20200423204116_add_sort_fields.go +++ b/db/migrations/20200423204116_add_sort_fields.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20200423204116, Down20200423204116) } -func Up20200423204116(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200423204116(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add order_artist_name varchar(255) collate nocase; alter table artist @@ -57,10 +57,10 @@ create index if not exists media_file_order_artist_name if err != nil { return err } - notice(tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(ctx, tx) } -func Down20200423204116(_ context.Context, tx *sql.Tx) error { +func Down20200423204116(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200508093059_add_artist_song_count.go b/db/migrations/20200508093059_add_artist_song_count.go index aac78e698..72a47bc94 100644 --- a/db/migrations/20200508093059_add_artist_song_count.go +++ b/db/migrations/20200508093059_add_artist_song_count.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(Up20200508093059, Down20200508093059) } -func Up20200508093059(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200508093059(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add song_count integer default 0 not null; `) if err != nil { return err } - notice(tx, "A full rescan will be performed to calculate artists' song counts") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts") + return forceFullRescan(ctx, tx) } -func Down20200508093059(_ context.Context, tx *sql.Tx) error { +func Down20200508093059(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go index b3e907d8d..29734e0c0 100644 --- a/db/migrations/20200512104202_add_disc_subtitle.go +++ b/db/migrations/20200512104202_add_disc_subtitle.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(Up20200512104202, Down20200512104202) } -func Up20200512104202(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200512104202(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add disc_subtitle varchar(255); `) if err != nil { return err } - notice(tx, "A full rescan will be performed to import disc subtitles") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to import disc subtitles") + return forceFullRescan(ctx, tx) } -func Down20200512104202(_ context.Context, tx *sql.Tx) error { +func Down20200512104202(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migrations/20200516140647_add_playlist_tracks_table.go index fcaae9d8e..59265e410 100644 --- a/db/migrations/20200516140647_add_playlist_tracks_table.go +++ b/db/migrations/20200516140647_add_playlist_tracks_table.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(Up20200516140647, Down20200516140647) } -func Up20200516140647(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20200516140647(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists playlist_tracks ( id integer default 0 not null, @@ -28,7 +28,7 @@ create unique index if not exists playlist_tracks_pos if err != nil { return err } - rows, err := tx.Query("select id, tracks from playlist") + rows, err := tx.QueryContext(ctx, "select id, tracks from playlist") if err != nil { return err } @@ -49,7 +49,7 @@ create unique index if not exists playlist_tracks_pos return err } - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -96,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) return nil } -func Down20200516140647(_ context.Context, tx *sql.Tx) error { +func Down20200516140647(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go index 2959237fa..c9c766f7e 100644 --- a/db/migrations/20200608153717_referential_integrity.go +++ b/db/migrations/20200608153717_referential_integrity.go @@ -11,46 +11,46 @@ func init() { goose.AddMigrationContext(Up20200608153717, Down20200608153717) } -func Up20200608153717(_ context.Context, tx *sql.Tx) error { +func Up20200608153717(ctx context.Context, tx *sql.Tx) error { // First delete dangling players - _, err := tx.Exec(` + _, err := tx.ExecContext(ctx, ` delete from player where user_name not in (select user_name from user)`) if err != nil { return err } // Also delete dangling players - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` delete from playlist where owner not in (select user_name from user)`) if err != nil { return err } // Also delete dangling playlist tracks - _, err = tx.Exec(` + _, err = tx.ExecContext(ctx, ` delete from playlist_tracks where playlist_id not in (select id from playlist)`) if err != nil { return err } // Add foreign key to player table - err = updatePlayer_20200608153717(tx) + err = updatePlayer_20200608153717(ctx, tx) if err != nil { return err } // Add foreign key to playlist table - err = updatePlaylist_20200608153717(tx) + err = updatePlaylist_20200608153717(ctx, tx) if err != nil { return err } // Add foreign keys to playlist_tracks table - return updatePlaylistTracks_20200608153717(tx) + return updatePlaylistTracks_20200608153717(ctx, tx) } -func updatePlayer_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table player_dg_tmp ( id varchar(255) not null @@ -77,8 +77,8 @@ alter table player_dg_tmp rename to player; return err } -func updatePlaylist_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -108,8 +108,8 @@ create index playlist_name return err } -func updatePlaylistTracks_20200608153717(tx *sql.Tx) error { - _, err := tx.Exec(` +func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_tracks_dg_tmp ( id integer default 0 not null, @@ -133,6 +133,6 @@ create unique index playlist_tracks_pos return err } -func Down20200608153717(_ context.Context, tx *sql.Tx) error { +func Down20200608153717(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go index a498d32b0..e87481ae1 100644 --- a/db/migrations/20200706231659_add_default_transcodings.go +++ b/db/migrations/20200706231659_add_default_transcodings.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings) } -func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { - row := tx.QueryRow("SELECT COUNT(*) FROM transcoding") +func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding") var count int err := row.Scan(&count) if err != nil { @@ -38,6 +38,6 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go index 8abfed6cf..32cc8d034 100644 --- a/db/migrations/20200710211442_add_playlist_path.go +++ b/db/migrations/20200710211442_add_playlist_path.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath) } -func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add path string default '' not null; @@ -23,6 +23,6 @@ alter table playlist return err } -func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error { +func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migrations/20200731095603_create_play_queues_table.go index d63a1ecb9..7a27137bc 100644 --- a/db/migrations/20200731095603_create_play_queues_table.go +++ b/db/migrations/20200731095603_create_play_queues_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable) } -func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playqueue ( id varchar(255) not null primary key, @@ -32,6 +32,6 @@ create table playqueue return err } -func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { +func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go index fe68fafd7..df814d7b8 100644 --- a/db/migrations/20200801101355_create_bookmark_table.go +++ b/db/migrations/20200801101355_create_bookmark_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable) } -func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table bookmark ( user_id varchar(255) not null @@ -49,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue; return err } -func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { +func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migrations/20200819111809_drop_email_unique_constraint.go index b2dd4285c..8259ad3fe 100644 --- a/db/migrations/20200819111809_drop_email_unique_constraint.go +++ b/db/migrations/20200819111809_drop_email_unique_constraint.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint) } -func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_dg_tmp ( id varchar(255) not null @@ -38,6 +38,6 @@ alter table user_dg_tmp rename to user; return err } -func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { +func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201003111749_add_starred_at_index.go b/db/migrations/20201003111749_add_starred_at_index.go index 7ee7a283f..b46430743 100644 --- a/db/migrations/20201003111749_add_starred_at_index.go +++ b/db/migrations/20201003111749_add_starred_at_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201003111749, Down20201003111749) } -func Up20201003111749(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201003111749(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists annotation_starred_at on annotation (starred_at); `) return err } -func Down20201003111749(_ context.Context, tx *sql.Tx) error { +func Down20201003111749(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go index f1182ab6c..df1fa8ca2 100644 --- a/db/migrations/20201010162350_add_album_size.go +++ b/db/migrations/20201010162350_add_album_size.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201010162350, Down20201010162350) } -func Up20201010162350(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201010162350(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add size integer default 0 not null; create index if not exists album_size @@ -28,7 +28,7 @@ where id not null;`) return err } -func Down20201010162350(_ context.Context, tx *sql.Tx) error { +func Down20201010162350(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201012210022_add_artist_playlist_size.go b/db/migrations/20201012210022_add_artist_playlist_size.go index 4eb67f14e..1c738dd1e 100644 --- a/db/migrations/20201012210022_add_artist_playlist_size.go +++ b/db/migrations/20201012210022_add_artist_playlist_size.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201012210022, Down20201012210022) } -func Up20201012210022(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201012210022(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add size integer default 0 not null; create index if not exists artist_size @@ -40,6 +40,6 @@ update playlist set size = ifnull(( return err } -func Down20201012210022(_ context.Context, tx *sql.Tx) error { +func Down20201012210022(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go index 624bb1a67..53001fc73 100644 --- a/db/migrations/20201021085410_add_mbids.go +++ b/db/migrations/20201021085410_add_mbids.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201021085410, Down20201021085410) } -func Up20201021085410(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021085410(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add mbz_track_id varchar(255); alter table media_file @@ -49,11 +49,11 @@ alter table artist if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func Down20201021085410(_ context.Context, tx *sql.Tx) error { +func Down20201021085410(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201021093209_add_media_file_indexes.go b/db/migrations/20201021093209_add_media_file_indexes.go index f3a800949..7d6ad4965 100644 --- a/db/migrations/20201021093209_add_media_file_indexes.go +++ b/db/migrations/20201021093209_add_media_file_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201021093209, Down20201021093209) } -func Up20201021093209(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021093209(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_artist on media_file (artist); create index if not exists media_file_album_artist @@ -23,6 +23,6 @@ create index if not exists media_file_mbz_track_id return err } -func Down20201021093209(_ context.Context, tx *sql.Tx) error { +func Down20201021093209(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201021135455_add_media_file_artist_index.go b/db/migrations/20201021135455_add_media_file_artist_index.go index ca04d8a20..e8f22c3a7 100644 --- a/db/migrations/20201021135455_add_media_file_artist_index.go +++ b/db/migrations/20201021135455_add_media_file_artist_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201021135455, Down20201021135455) } -func Up20201021135455(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201021135455(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists media_file_artist_id on media_file (artist_id); `) return err } -func Down20201021135455(_ context.Context, tx *sql.Tx) error { +func Down20201021135455(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migrations/20201030162009_add_artist_info_table.go index f2917ae49..e33e15c23 100644 --- a/db/migrations/20201030162009_add_artist_info_table.go +++ b/db/migrations/20201030162009_add_artist_info_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl) } -func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table artist add biography varchar(255) default '' not null; alter table artist @@ -31,6 +31,6 @@ alter table artist return err } -func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { +func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201110205344_add_comments_and_lyrics.go b/db/migrations/20201110205344_add_comments_and_lyrics.go index 5bb17b8d0..c60917bdd 100644 --- a/db/migrations/20201110205344_add_comments_and_lyrics.go +++ b/db/migrations/20201110205344_add_comments_and_lyrics.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(Up20201110205344, Down20201110205344) } -func Up20201110205344(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201110205344(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add comment varchar; alter table media_file @@ -24,10 +24,10 @@ alter table album if err != nil { return err } - notice(tx, "A full rescan will be performed to import comments and lyrics") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan will be performed to import comments and lyrics") + return forceFullRescan(ctx, tx) } -func Down20201110205344(_ context.Context, tx *sql.Tx) error { +func Down20201110205344(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201128100726_add_real-path_option.go b/db/migrations/20201128100726_add_real-path_option.go index db102dfa9..4b3f62128 100644 --- a/db/migrations/20201128100726_add_real-path_option.go +++ b/db/migrations/20201128100726_add_real-path_option.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(Up20201128100726, Down20201128100726) } -func Up20201128100726(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201128100726(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table player add report_real_path bool default FALSE not null; `) return err } -func Down20201128100726(_ context.Context, tx *sql.Tx) error { +func Down20201128100726(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migrations/20201213124814_add_all_artist_ids_to_album.go index 170497f5c..81c30d611 100644 --- a/db/migrations/20201213124814_add_all_artist_ids_to_album.go +++ b/db/migrations/20201213124814_add_all_artist_ids_to_album.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(Up20201213124814, Down20201213124814) } -func Up20201213124814(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func Up20201213124814(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add all_artist_ids varchar; @@ -25,11 +25,11 @@ create index if not exists album_all_artist_ids return err } - return updateAlbums20201213124814(tx) + return updateAlbums20201213124814(ctx, tx) } -func updateAlbums20201213124814(tx *sql.Tx) error { - rows, err := tx.Query(` +func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, ` select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ') from album a left join media_file mf on a.id = mf.album_id group by a.id `) @@ -59,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, return rows.Err() } -func Down20201213124814(_ context.Context, tx *sql.Tx) error { +func Down20201213124814(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go index 3341dd3d2..5ed250fea 100644 --- a/db/migrations/20210322132848_add_timestamp_indexes.go +++ b/db/migrations/20210322132848_add_timestamp_indexes.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo) } -func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index if not exists album_updated_at on album (updated_at); create index if not exists album_created_at @@ -29,6 +29,6 @@ create index if not exists media_file_updated_at return err } -func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { +func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go index 59067640a..3c7ed86c1 100644 --- a/db/migrations/20210418232815_fix_album_comments.go +++ b/db/migrations/20210418232815_fix_album_comments.go @@ -14,10 +14,10 @@ func init() { goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments) } -func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { +func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.Query(` - SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; + rows, err := tx.QueryContext(ctx, ` + SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; `) if err != nil { return err @@ -49,7 +49,7 @@ func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { return rows.Err() } -func downFixAlbumComments(_ context.Context, tx *sql.Tx) error { +func downFixAlbumComments(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go index 721c9e179..00a0f1447 100644 --- a/db/migrations/20210430212322_add_bpm_metadata.go +++ b/db/migrations/20210430212322_add_bpm_metadata.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata) } -func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add bpm integer; @@ -22,10 +22,10 @@ create index if not exists media_file_bpm if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error { +func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go index e9208bd69..d9e902a43 100644 --- a/db/migrations/20210530121921_create_shares_table.go +++ b/db/migrations/20210530121921_create_shares_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable) } -func upCreateSharesTable(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table share ( id varchar(255) not null primary key, @@ -30,6 +30,6 @@ create table share return err } -func downCreateSharesTable(_ context.Context, tx *sql.Tx) error { +func downCreateSharesTable(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go index 965c0186e..5a459a34c 100644 --- a/db/migrations/20210601231734_update_share_fieldnames.go +++ b/db/migrations/20210601231734_update_share_fieldnames.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames) } -func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share rename column expires to expires_at; alter table share rename column created to created_at; alter table share rename column last_visited to last_visited_at; @@ -21,6 +21,6 @@ alter table share rename column last_visited to last_visited_at; return err } -func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { +func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go index f67e3fb0a..dc8a9abd4 100644 --- a/db/migrations/20210616150710_encrypt_all_passwords.go +++ b/db/migrations/20210616150710_encrypt_all_passwords.go @@ -16,7 +16,7 @@ func init() { } func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`SELECT id, user_name, password from user;`) + rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`) if err != nil { return err } @@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error { +func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migrations/20210619231716_drop_player_name_unique_constraint.go index 200332156..734ffc340 100644 --- a/db/migrations/20210619231716_drop_player_name_unique_constraint.go +++ b/db/migrations/20210619231716_drop_player_name_unique_constraint.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint) } -func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table player_dg_tmp ( id varchar(255) not null @@ -43,6 +43,6 @@ create index if not exists player_name return err } -func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { +func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go index 5257dfab3..aa5e7a8f0 100644 --- a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go +++ b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled) } -func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { - err := upAddUserPrefs(tx) +func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { + err := upAddUserPrefs(ctx, tx) if err != nil { return err } - return upPlayerScrobblerEnabled(tx) + return upPlayerScrobblerEnabled(ctx, tx) } -func upAddUserPrefs(tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_props ( user_id varchar not null, @@ -33,13 +33,13 @@ create table user_props return err } -func upPlayerScrobblerEnabled(tx *sql.Tx) error { - _, err := tx.Exec(` +func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table player add scrobble_enabled bool default true; `) return err } -func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { +func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go index 033392d93..b2f93b4e3 100644 --- a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go +++ b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps) } -func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table user_props_dg_tmp ( user_id varchar not null @@ -34,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props; return err } -func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { +func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go index 1c4d0de2a..75d9d681c 100644 --- a/db/migrations/20210626213026_add_scrobble_buffer.go +++ b/db/migrations/20210626213026_add_scrobble_buffer.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer) } -func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists scrobble_buffer ( user_id varchar not null @@ -34,6 +34,6 @@ create table if not exists scrobble_buffer return err } -func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { +func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go index ab2c54239..143f9c72b 100644 --- a/db/migrations/20210715151153_add_genre_tables.go +++ b/db/migrations/20210715151153_add_genre_tables.go @@ -11,9 +11,9 @@ func init() { goose.AddMigrationContext(upAddGenreTables, downAddGenreTables) } -func upAddGenreTables(_ context.Context, tx *sql.Tx) error { - notice(tx, "A full rescan will be performed to import multiple genres!") - _, err := tx.Exec(` +func upAddGenreTables(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "A full rescan will be performed to import multiple genres!") + _, err := tx.ExecContext(ctx, ` create table if not exists genre ( id varchar not null primary key, @@ -61,9 +61,9 @@ create table if not exists artist_genres if err != nil { return err } - return forceFullRescan(tx) + return forceFullRescan(ctx, tx) } -func downAddGenreTables(_ context.Context, tx *sql.Tx) error { +func downAddGenreTables(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go index 9a0988b17..ee18be01b 100644 --- a/db/migrations/20210821212604_add_mediafile_channels.go +++ b/db/migrations/20210821212604_add_mediafile_channels.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels) } -func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add channels integer; @@ -22,10 +22,10 @@ create index if not exists media_file_channels if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error { +func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go index c8ed67c47..0d2d1ad4e 100644 --- a/db/migrations/20211008205505_add_smart_playlist.go +++ b/db/migrations/20211008205505_add_smart_playlist.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist) } -func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table playlist add column rules varchar null; alter table playlist @@ -33,6 +33,6 @@ create unique index playlist_fields_idx return err } -func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { +func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migrations/20211023184825_add_order_title_to_media_file.go index ee6fc67d1..4a2ae4047 100644 --- a/db/migrations/20211023184825_add_order_title_to_media_file.go +++ b/db/migrations/20211023184825_add_order_title_to_media_file.go @@ -14,8 +14,8 @@ func init() { goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile) } -func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table main.media_file add order_title varchar null collate NOCASE; create index if not exists media_file_order_title @@ -25,12 +25,12 @@ create index if not exists media_file_order_title return err } - return upAddOrderTitleToMediaFile_populateOrderTitle(tx) + return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx) } //goland:noinspection GoSnakeCaseUsage -func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { - rows, err := tx.Query(`select id, title from media_file`) +func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, title from media_file`) if err != nil { return err } @@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { return rows.Err() } -func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { +func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migrations/20211026191915_unescape_lyrics_and_comments.go index d4ba5e194..a7969ffed 100644 --- a/db/migrations/20211026191915_unescape_lyrics_and_comments.go +++ b/db/migrations/20211026191915_unescape_lyrics_and_comments.go @@ -13,8 +13,8 @@ func init() { goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments) } -func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`select id, comment, lyrics, title from media_file`) +func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`) if err != nil { return err } @@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { return rows.Err() } -func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { +func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migrations/20211029213200_add_userid_to_playlist.go index e262fc205..909ea1c54 100644 --- a/db/migrations/20211029213200_add_userid_to_playlist.go +++ b/db/migrations/20211029213200_add_userid_to_playlist.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist) } -func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table playlist_dg_tmp ( id varchar(255) not null @@ -56,6 +56,6 @@ create index playlist_updated_at return err } -func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { +func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go index 4ab4305d0..f786b69e7 100644 --- a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go +++ b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex) } -func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create index album_alphabetical_by_artist ON album(compilation, order_album_artist_name, order_album_name) `) return err } -func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { +func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20211105162746_remove_invalid_artist_ids.go b/db/migrations/20211105162746_remove_invalid_artist_ids.go index 5e078c820..8c9887dd1 100644 --- a/db/migrations/20211105162746_remove_invalid_artist_ids.go +++ b/db/migrations/20211105162746_remove_invalid_artist_ids.go @@ -11,13 +11,13 @@ func init() { goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds) } -func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id) `) return err } -func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { +func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go index 481762117..42e13a1e5 100644 --- a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go +++ b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go @@ -11,19 +11,19 @@ func init() { goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId) } -func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add mbz_release_track_id varchar(255); `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { +func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20221219112733_add_album_image_paths.go b/db/migrations/20221219112733_add_album_image_paths.go index ee9c77c8a..f8ebd40e9 100644 --- a/db/migrations/20221219112733_add_album_image_paths.go +++ b/db/migrations/20221219112733_add_album_image_paths.go @@ -11,17 +11,17 @@ func init() { goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths) } -func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table main.album add image_files varchar; `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(ctx, tx) } -func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { +func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20221219140528_remove_cover_art_id.go b/db/migrations/20221219140528_remove_cover_art_id.go index a1eaa89f9..30f86297a 100644 --- a/db/migrations/20221219140528_remove_cover_art_id.go +++ b/db/migrations/20221219140528_remove_cover_art_id.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId) } -func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album drop column cover_art_id; alter table album rename column cover_art_path to embed_art_path `) if err != nil { return err } - notice(tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(ctx, tx) } -func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { +func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go index 2dfb9a747..2819522a1 100644 --- a/db/migrations/20230112111457_add_album_paths.go +++ b/db/migrations/20230112111457_add_album_paths.go @@ -16,15 +16,15 @@ func init() { goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths) } -func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`alter table album add paths varchar;`) +func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `alter table album add paths varchar;`) if err != nil { return err } //nolint:gosec - rows, err := tx.Query(` - select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + rows, err := tx.QueryContext(ctx, ` + select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id `) if err != nil { return err @@ -63,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string { return strings.Join(dirs, string(filepath.ListSeparator)) } -func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error { +func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go index 0f10e275c..71959b0a8 100644 --- a/db/migrations/20230114121537_touch_playlists.go +++ b/db/migrations/20230114121537_touch_playlists.go @@ -11,11 +11,11 @@ func init() { goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists) } -func upTouchPlaylists(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`update playlist set updated_at = datetime('now');`) +func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`) return err } -func downTouchPlaylists(_ context.Context, tx *sql.Tx) error { +func downTouchPlaylists(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go index 5c014dac2..3e0da348f 100644 --- a/db/migrations/20230115103212_create_internet_radio.go +++ b/db/migrations/20230115103212_create_internet_radio.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio) } -func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` create table if not exists radio ( id varchar(255) not null primary key, @@ -26,6 +26,6 @@ create table if not exists radio return err } -func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error { +func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go index d6be3b313..3aad70925 100644 --- a/db/migrations/20230117155559_add_replaygain_metadata.go +++ b/db/migrations/20230117155559_add_replaygain_metadata.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata) } -func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add rg_album_gain real; alter table media_file add @@ -26,10 +26,10 @@ alter table media_file add return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { +func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go index 5d6dd8230..750d3838f 100644 --- a/db/migrations/20230117180400_add_album_info.go +++ b/db/migrations/20230117180400_add_album_info.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo) } -func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table album add description varchar(255) default '' not null; alter table album @@ -29,6 +29,6 @@ alter table album return err } -func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error { +func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go index e1ae816c0..10eff31ca 100644 --- a/db/migrations/20230119152657_recreate_share_table.go +++ b/db/migrations/20230119152657_recreate_share_table.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo) } -func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` drop table if exists share; create table share ( @@ -37,6 +37,6 @@ create table share return err } -func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { +func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migrations/20230202143713_change_path_list_separator.go index 78b030ae4..fb5f2be1a 100644 --- a/db/migrations/20230202143713_change_path_list_separator.go +++ b/db/migrations/20230202143713_change_path_list_separator.go @@ -16,10 +16,10 @@ func init() { goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator) } -func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error { +func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.Query(` - select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + rows, err := tx.QueryContext(ctx, ` + select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id `) if err != nil { return err @@ -58,6 +58,6 @@ func upChangePathListSeparatorDirs(filePaths string) string { return strings.Join(dirs, consts.Zwsp) } -func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error { +func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migrations/20230209181414_change_image_files_list_separator.go index 7f4d4cb0e..e5dc4ab43 100644 --- a/db/migrations/20230209181414_change_image_files_list_separator.go +++ b/db/migrations/20230209181414_change_image_files_list_separator.go @@ -16,8 +16,8 @@ func init() { goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator) } -func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { - rows, err := tx.Query(`select id, image_files from album`) +func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, image_files from album`) if err != nil { return err } @@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string { return strings.Join(allPaths, consts.Zwsp) } -func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { +func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go index ed2879ec3..3ee24cc77 100644 --- a/db/migrations/20230310222612_add_download_to_share.go +++ b/db/migrations/20230310222612_add_download_to_share.go @@ -11,14 +11,14 @@ func init() { goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare) } -func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table share add downloadable bool not null default false; `) return err } -func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error { +func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go index 1141a1e74..f22bdfae8 100644 --- a/db/migrations/20230515184510_add_release_date.go +++ b/db/migrations/20230515184510_add_release_date.go @@ -11,8 +11,8 @@ func init() { goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear) } -func upAddRelRecYear(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file add date varchar(255) default '' not null; alter table media_file @@ -41,10 +41,10 @@ alter table album return err } - notice(tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(tx) + notice(ctx, tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(ctx, tx) } -func downAddRelRecYear(_ context.Context, tx *sql.Tx) error { +func downAddRelRecYear(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go index 170fc264c..562a59bb5 100644 --- a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go +++ b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go @@ -11,16 +11,16 @@ func init() { goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId) } -func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_track_id to mbz_recording_id; `) return err } -func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(` +func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table media_file rename column mbz_recording_id to mbz_track_id; `) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index ac73fc98f..7f1ad2f38 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) + rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) if err != nil { return err } @@ -46,12 +46,12 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { continue } - lyrics, err := model.ToLyrics("xxx", lyrics.String) + parsed, err := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(lyrics.String)) if err != nil { return err } - text, err := json.Marshal(model.LyricList{*lyrics}) + text, err := json.Marshal(parsed) if err != nil { return err } @@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") + notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") return nil } diff --git a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go index a65b0aefd..518d125e4 100644 --- a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go +++ b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go @@ -558,6 +558,6 @@ create index media_file_mbz_track_id return err } -func Down20240122223340(context.Context, *sql.Tx) error { +func Down20240122223340(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20240511210036_add_sample_rate.go b/db/migrations/20240511210036_add_sample_rate.go index 619cdcffd..76b809c36 100644 --- a/db/migrations/20240511210036_add_sample_rate.go +++ b/db/migrations/20240511210036_add_sample_rate.go @@ -19,7 +19,7 @@ alter table media_file create index if not exists media_file_sample_rate on media_file (sample_rate); `) - notice(tx, "A full rescan should be performed to pick up additional tags") + notice(ctx, tx, "A full rescan should be performed to pick up additional tags") return err } diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go index b450b26d4..972932e10 100644 --- a/db/migrations/20240629152843_remove_annotation_id.go +++ b/db/migrations/20240629152843_remove_annotation_id.go @@ -61,6 +61,6 @@ create index annotation_starred_at return err } -func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error { +func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index fcbef7e4e..f5899f08f 100644 --- a/db/migrations/20241026183640_support_new_scanner.go +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -97,8 +97,8 @@ insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing; `), func() error { - notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.") - return forceFullRescan(tx) + notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.") + return forceFullRescan(ctx, tx) }, ) } @@ -314,6 +314,6 @@ alter table artist } } -func downSupportNewScanner(context.Context, *sql.Tx) error { +func downSupportNewScanner(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go index d9250eba2..1b83c0b35 100644 --- a/db/migrations/20250611010101_playqueue_current_to_index.go +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -75,6 +75,6 @@ create table playqueue_dg_tmp( return err } -func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { +func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go index e82a0749f..c350d31f5 100644 --- a/db/migrations/20250701010101_add_folder_hash.go +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -16,6 +16,6 @@ func upAddFolderHash(ctx context.Context, tx *sql.Tx) error { return err } -func downAddFolderHash(ctx context.Context, tx *sql.Tx) error { +func downAddFolderHash(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go index 8025229cc..a84758a04 100644 --- a/db/migrations/20250701010103_add_library_stats.go +++ b/db/migrations/20250701010103_add_library_stats.go @@ -43,6 +43,6 @@ update library set return err } -func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error { +func downAddLibraryStats(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go index 163608d32..c6beb2a51 100644 --- a/db/migrations/20250701010104_make_replaygain_fields_nullable.go +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -39,7 +39,7 @@ ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; return err } - notice(tx, "Fetching replaygain fields properly will require a full scan") + notice(ctx, tx, "Fetching replaygain fields properly will require a full scan") return nil } diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go index dc4cd647b..6f2bde429 100644 --- a/db/migrations/20260220173400_add_fts5_search.go +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -22,7 +22,7 @@ func stripPunct(col string) string { } func upAddFts5Search(ctx context.Context, tx *sql.Tx) error { - notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") + notice(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go index 4e8b1b7f5..f52f48440 100644 --- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -12,20 +12,20 @@ func init() { goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) } -func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { +func upAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { // Add codec column to media_file. - _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) if err != nil { return err } - _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) if err != nil { return err } // Update old AAC default (adts) to new default (ipod with fragmented MP4). // Only affects users who still have the unmodified old default command. - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", @@ -36,12 +36,12 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { // Add FLAC transcoding for existing installations that were seeded before FLAC was added. var count int - err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), "flac audio", "flac", 0, "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", @@ -52,22 +52,22 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { } // Add probe_data column for caching ffprobe results. - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) if err != nil { return err } return nil } -func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) +func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) + _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`) return err } diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go index a7e7366ed..c76d6ed1a 100644 --- a/db/migrations/20260309120007_fix_probe_data_null.go +++ b/db/migrations/20260309120007_fix_probe_data_null.go @@ -11,18 +11,18 @@ func init() { goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull) } -func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error { // Recreate probe_data column as NOT NULL with empty string default. // The previous migration created it with DEFAULT NULL, which causes // scan errors when reading into Go string fields. - _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) + _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) return err } -func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error { +func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index ab6d24952..4df66d26d 100644 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -13,7 +13,7 @@ func init() { goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings) } -func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { // Older installations may be missing default transcodings that were added // after the initial seeding (e.g., aac was added later than mp3/opus). // Insert any missing defaults without touching user-customized entries. @@ -22,12 +22,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { // but the same name. for _, t := range consts.DefaultTranscodings { var count int - err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) + err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.Exec( + _, err = tx.ExecContext(ctx, "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command, ) @@ -39,6 +39,6 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } -func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error { +func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go index 588137383..a4fa8fcc1 100644 --- a/db/migrations/20260310113858_fix_aac_transcode_command.go +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -11,20 +11,20 @@ func init() { goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand) } -func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error { // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+). // Switch to `-f adts` (raw AAC framing) which works reliably via pipe. // Only update rows that still have the old default command. const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -" const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -" - _, err := tx.Exec( + _, err := tx.ExecContext(ctx, "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", newCommand, oldCommand, ) return err } -func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error { +func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error { return nil } diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go new file mode 100644 index 000000000..472ae7b43 --- /dev/null +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput) +} + +// ssSeekPairs maps old commands (output seeking) to new commands (input seeking). +// Index 0 = old (after -i), index 1 = new (before -i). +var ssSeekPairs = [][2]string{ + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql new file mode 100644 index 000000000..f65050d80 --- /dev/null +++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role + ON media_file_artists (media_file_id, role); +DROP INDEX IF EXISTS media_file_artists_media_file_id; + +-- +goose Down +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id + ON media_file_artists (media_file_id); +DROP INDEX IF EXISTS media_file_artists_media_file_id_role; diff --git a/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql new file mode 100644 index 000000000..8bd8ad5b8 --- /dev/null +++ b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Covering index for the rowid-only pagination query used by search3 with an empty query +-- (full library sync). It must cover both `missing` and `library_id` so SQLite never touches +-- the (wide) media_file rows while skipping over large offsets. +-- Replaces media_file_missing: the composite serves all `missing = ?` lookups via its prefix. +create index if not exists media_file_missing_library_id + on media_file(missing, library_id); +drop index if exists media_file_missing; + +-- +goose Down +create index if not exists media_file_missing + on media_file(missing); +drop index if exists media_file_missing_library_id; diff --git a/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql b/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql new file mode 100644 index 000000000..c84323158 --- /dev/null +++ b/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql @@ -0,0 +1,35 @@ +-- +goose Up +drop index if exists media_file_bpm; + +alter table media_file add column bpm_new integer; +alter table media_file add column bit_depth_new integer; + +update media_file set + bpm_new = nullif(bpm, 0), + bit_depth_new = nullif(bit_depth, 0); + +alter table media_file drop column bpm; +alter table media_file drop column bit_depth; + +alter table media_file rename column bpm_new to bpm; +alter table media_file rename column bit_depth_new to bit_depth; + +create index if not exists media_file_bpm on media_file (bpm); + +-- +goose Down +drop index if exists media_file_bpm; + +alter table media_file add column bpm_old integer default 0 not null; +alter table media_file add column bit_depth_old integer default 0 not null; + +update media_file set + bpm_old = coalesce(bpm, 0), + bit_depth_old = coalesce(bit_depth, 0); + +alter table media_file drop column bpm; +alter table media_file drop column bit_depth; + +alter table media_file rename column bpm_old to bpm; +alter table media_file rename column bit_depth_old to bit_depth; + +create index if not exists media_file_bpm on media_file (bpm); diff --git a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go new file mode 100644 index 000000000..2186cda91 --- /dev/null +++ b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go @@ -0,0 +1,64 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMetadataToDefaultTranscodings, downAddMetadataToDefaultTranscodings) +} + +// metadataPairs maps the current default commands (no metadata mapping) to the +// new defaults that preserve source tags. Index 0 = old, index 1 = new. +// +// The new commands add `-map_metadata 0 -map_metadata 0:s:a:0` after `-map 0:a:0`: +// `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and +// `-map_metadata 0:s:a:0` copies tags from the first audio stream (OPUS/OGG +// sources); both are needed because the two source families store tags at +// different levels. Targeting the audio stream explicitly avoids pulling +// metadata from an embedded cover-art/video stream at index 0. +// +// AAC is included for consistency, but its `-f adts` container cannot hold tags, +// so the flags are a no-op there. +// +// Only rows still holding the exact unmodified default are updated, so any +// user-customized command is left untouched. +var metadataPairs = [][2]string{ + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { + for _, p := range metadataPairs { + if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/db/migrations/20260629123100_recently_added_plain_indexes.sql b/db/migrations/20260629123100_recently_added_plain_indexes.sql new file mode 100644 index 000000000..731959e62 --- /dev/null +++ b/db/migrations/20260629123100_recently_added_plain_indexes.sql @@ -0,0 +1,28 @@ +-- +goose Up + +-- The "Recently Added" sort now uses the raw timestamp with an id tiebreak +-- instead of datetime(), so the indexes become plain composite (col, id) to +-- cover it. Timestamps were already normalized to space-format by +-- 20260316000000_normalize_timestamps, so raw-string comparison is safe. + +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(created_at, id); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(updated_at, id); + +DROP INDEX IF EXISTS media_file_created_at; +CREATE INDEX media_file_created_at ON media_file(created_at, id); +DROP INDEX IF EXISTS media_file_updated_at; +CREATE INDEX media_file_updated_at ON media_file(updated_at, id); + +-- +goose Down + +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(datetime(created_at)); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(datetime(updated_at)); + +DROP INDEX IF EXISTS media_file_created_at; +CREATE INDEX media_file_created_at ON media_file(created_at); +DROP INDEX IF EXISTS media_file_updated_at; +CREATE INDEX media_file_updated_at ON media_file(updated_at); diff --git a/db/migrations/20260702152457_backfill_artist_search_normalized.go b/db/migrations/20260702152457_backfill_artist_search_normalized.go new file mode 100644 index 000000000..93902e230 --- /dev/null +++ b/db/migrations/20260702152457_backfill_artist_search_normalized.go @@ -0,0 +1,58 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/navidrome/navidrome/utils/str" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upBackfillArtistSearchNormalized, downBackfillArtistSearchNormalized) +} + +// The FTS5 migration back-filled artist.search_normalized with a SQL approximation that +// cannot transliterate atomic letters (Ø, æ, ß, ...), and the scanner never rewrote the +// column, leaving artists like "GØGGS" unfindable by ASCII searches. Recompute it in Go; +// the artist_fts update trigger re-indexes every row that changes. +func upBackfillArtistSearchNormalized(ctx context.Context, tx *sql.Tx) error { + notice(ctx, tx, "Rebuilding artist search index data. This may take a moment on large libraries.") + + rows, err := tx.QueryContext(ctx, "SELECT id, name, search_normalized FROM artist") + if err != nil { + return fmt.Errorf("querying artists: %w", err) + } + defer rows.Close() + + updates := map[string]string{} + for rows.Next() { + var id, name, current string + if err := rows.Scan(&id, &name, ¤t); err != nil { + return fmt.Errorf("scanning artist: %w", err) + } + if expected := str.NormalizeForFTS(name); expected != current { + updates[id] = expected + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterating artists: %w", err) + } + + stmt, err := tx.PrepareContext(ctx, "UPDATE artist SET search_normalized = ? WHERE id = ?") + if err != nil { + return fmt.Errorf("preparing update: %w", err) + } + defer stmt.Close() + for id, normalized := range updates { + if _, err := stmt.ExecContext(ctx, normalized, id); err != nil { + return fmt.Errorf("updating artist %s: %w", id, err) + } + } + return nil +} + +func downBackfillArtistSearchNormalized(context.Context, *sql.Tx) error { + return nil +} diff --git a/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql b/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql new file mode 100644 index 000000000..dd36bf4c2 --- /dev/null +++ b/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- +goose StatementBegin + +-- Composite indexes matching the media_file sort mappings for album, artist and +-- albumArtist. Without them, SQLite cannot satisfy the multi-column ORDER BY and +-- falls back to a full scan + temp B-tree sort of the whole table (including all +-- its large columns) even for a small LIMIT. +create index if not exists media_file_album_sort + on media_file(order_album_name, album_id, disc_number, track_number, order_artist_name, title); +create index if not exists media_file_artist_sort + on media_file(order_artist_name, order_album_name, release_date, disc_number, track_number); +create index if not exists media_file_album_artist_sort + on media_file(order_album_artist_name, order_album_name, release_date, disc_number, track_number); + +-- These two are strict prefixes of the composites above, so they are redundant now. +drop index if exists media_file_order_album_name; +drop index if exists media_file_order_artist_name; + +-- No query filters or sorts on these columns: birth_time is only read in Go code; +-- artist/album_artist conditions go through the media_file_artists table. +drop index if exists media_file_birth_time; +drop index if exists media_file_artist; +drop index if exists media_file_album_artist; + +-- These expression indexes are only usable when PreferSortTags is enabled, a +-- config used by ~0.1% of installations (per insights), yet they are maintained +-- on every write of every install. Dropping them means those installs fall back +-- to a full sort; everyone else saves the space and the scanner write overhead. +drop index if exists media_file_sort_title; +drop index if exists media_file_sort_artist_name; +drop index if exists media_file_sort_album_name; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop index if exists media_file_album_sort; +drop index if exists media_file_artist_sort; +drop index if exists media_file_album_artist_sort; + +create index if not exists media_file_order_album_name + on media_file(order_album_name); +create index if not exists media_file_order_artist_name + on media_file(order_artist_name); +create index if not exists media_file_birth_time + on media_file(birth_time); +create index if not exists media_file_artist + on media_file(artist); +create index if not exists media_file_album_artist + on media_file(album_artist); +create index if not exists media_file_sort_title + on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE); +create index if not exists media_file_sort_artist_name + on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE); +create index if not exists media_file_sort_album_name + on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE); +-- +goose StatementEnd diff --git a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql new file mode 100644 index 000000000..220d7cf75 --- /dev/null +++ b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql @@ -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); \ No newline at end of file diff --git a/db/migrations/20260714120000_add_playlist_average_rating.sql b/db/migrations/20260714120000_add_playlist_average_rating.sql new file mode 100644 index 000000000..5db642986 --- /dev/null +++ b/db/migrations/20260714120000_add_playlist_average_rating.sql @@ -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; diff --git a/db/migrations/migration.go b/db/migrations/migration.go index fde6f5817..df1c392a5 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -7,28 +7,20 @@ import ( "strings" "sync" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" ) // Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...) -func notice(tx *sql.Tx, msg string) { - if isDBInitialized(tx) { +func notice(ctx context.Context, tx *sql.Tx, msg string) { + if isDBInitialized(ctx, tx) { line := strings.Repeat("*", len(msg)+8) fmt.Printf("\n%s\nNOTICE: %s\n%s\n\n", line, msg, line) } } // Call this in migrations that requires a full rescan -func forceFullRescan(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.Exec(`ANALYZE;`) - if err != nil { - return err - } - } - _, err := tx.Exec(fmt.Sprintf(` +func forceFullRescan(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) return err @@ -44,9 +36,9 @@ var ( initialized bool ) -func isDBInitialized(tx *sql.Tx) bool { +func isDBInitialized(ctx context.Context, tx *sql.Tx) bool { once.Do(func() { - rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey) + rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey) checkErr(err) initialized = checkCount(rows) > 0 }) diff --git a/db/optimize.go b/db/optimize.go new file mode 100644 index 000000000..f46906c4e --- /dev/null +++ b/db/optimize.go @@ -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 +} diff --git a/db/optimize_test.go b/db/optimize_test.go new file mode 100644 index 000000000..da9b3b9c9 --- /dev/null +++ b/db/optimize_test.go @@ -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)) + }) +}) diff --git a/go.mod b/go.mod index 36218ba6b..014a43a56 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ 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-20260407173416-cf47afbaa67a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.3 + github.com/andybalholm/cascadia v1.3.4 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -19,31 +19,32 @@ 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.5.5 - github.com/go-chi/chi/v5 v5.2.5 + 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 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 - github.com/jellydator/ttlcache/v3 v3.4.0 - github.com/kardianos/service v1.2.4 + github.com/jellydator/ttlcache/v3 v3.4.1 + github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.1.0 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/lestrrat-go/jwx/v3 v3.1.1 + 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.28.3 - github.com/onsi/gomega v1.40.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 + 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 @@ -53,17 +54,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.0 + github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 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.39.0 - golang.org/x/net v0.53.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.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 ) @@ -74,29 +75,28 @@ 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 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.10.0 // indirect - github.com/fsnotify/fsnotify v1.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect 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-20260402051712-545e8a4df936 // 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-20260502231528-600b0e508b8c // 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 @@ -104,7 +104,7 @@ require ( github.com/lestrrat-go/dsig v1.3.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -115,7 +115,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -133,12 +133,12 @@ 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.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect - golang.org/x/tools v0.44.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.2 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 90e0ec040..064974edb 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -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-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +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/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= @@ -54,38 +53,37 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= 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.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= -github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= -github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= +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.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.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +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= @@ -101,13 +99,12 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/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= @@ -125,30 +122,27 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= 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= @@ -165,18 +159,18 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7 github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= -github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= -github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= 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.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +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= @@ -193,12 +187,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= -github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.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= @@ -207,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= @@ -224,8 +218,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -279,8 +273,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= -github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -295,7 +289,6 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -311,117 +304,57 @@ 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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +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.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= -gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= 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= diff --git a/log/journal.go b/log/journal.go index f1c17d2e7..dd7cf5400 100644 --- a/log/journal.go +++ b/log/journal.go @@ -36,6 +36,6 @@ func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) { if !ok { priority = 6 // default to info for unknown levels } - prefix := []byte(fmt.Sprintf("<%d>", priority)) + prefix := fmt.Appendf(nil, "<%d>", priority) return append(prefix, formatted...), nil } diff --git a/log/log.go b/log/log.go index 2764d80e5..eaea75fb9 100644 --- a/log/log.go +++ b/log/log.go @@ -175,10 +175,14 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } -func SetDefaultLogger(l *logrus.Logger) { +// SetDefaultLogger swaps the process-wide logger and returns the previous one, +// so tests can restore the original (with its hooks and formatter) on cleanup. +func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { loggerMu.Lock() defer loggerMu.Unlock() + prev := defaultLogger defaultLogger = l + return prev } func CurrentLevel() Level { @@ -193,34 +197,39 @@ func IsGreaterOrEqualTo(level Level) bool { } func Fatal(args ...any) { - Log(LevelFatal, args...) + log(LevelFatal, args...) os.Exit(1) } func Error(args ...any) { - Log(LevelError, args...) + log(LevelError, args...) } func Warn(args ...any) { - Log(LevelWarn, args...) + log(LevelWarn, args...) } func Info(args ...any) { - Log(LevelInfo, args...) + log(LevelInfo, args...) } func Debug(args ...any) { - Log(LevelDebug, args...) + log(LevelDebug, args...) } func Trace(args ...any) { - Log(LevelTrace, args...) + log(LevelTrace, args...) } func Log(level Level, args ...any) { + log(level, args...) +} + +func log(level Level, args ...any) { if !shouldLog(level, 3) { return } + logger, msg := parseArgs(args) logger.Log(logrus.Level(level), msg) } diff --git a/log/log_test.go b/log/log_test.go index a1f3b6ba0..7e1f3f3cc 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -135,18 +135,32 @@ var _ = Describe("Logger", func() { }) Describe("LogLevels", func() { - It("logs at specific levels", func() { - SetLevel(LevelError) - Debug("message 1") + BeforeEach(func() { + SetLevel(LevelFatal) + SetLogLevels(nil) + }) + + DescribeTable("logs at specific levels", func(logger func(...any), level Level) { + logger("message 1") Expect(hook.LastEntry()).To(BeNil()) - SetLogLevels(map[string]string{ - "log/log_test": "debug", - }) + Log(level, "message 1.5") + Expect(hook.LastEntry()).To(BeNil()) - Debug("message 2") + SetLogLevels(map[string]string{"log/log_test": "trace"}) + + logger("message 2") Expect(hook.LastEntry().Message).To(Equal("message 2")) - }) + + Log(level, "message 2.5") + Expect(hook.LastEntry().Message).To(Equal("message 2.5")) + }, + Entry("Error", Error, LevelError), + Entry("Warn", Warn, LevelWarn), + Entry("Info", Info, LevelInfo), + Entry("Debug", Debug, LevelDebug), + Entry("Trace", Trace, LevelTrace), + ) }) Describe("IsGreaterOrEqualTo", func() { diff --git a/model/artist_test.go b/model/artist_test.go index 5a24504eb..db897d3d5 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -14,7 +14,7 @@ var _ = Describe("Artist", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..ad66f7bb5 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -11,8 +11,7 @@ import ( var _ = Describe("ArtworkID", func() { Describe("NewArtworkID()", func() { It("creates a valid parseable ArtworkID", func() { - now := time.Now() - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now) + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 31d208d08..8c3d183a9 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -103,21 +103,26 @@ 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"` } 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')") } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 092cfd36a..7f214e703 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -80,6 +80,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() { diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go index 9f3f3922b..e2109aa1a 100644 --- a/model/criteria/export_test.go +++ b/model/criteria/export_test.go @@ -1,5 +1,3 @@ package criteria -var StartOfPeriod = startOfPeriod - type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 9eafff7ab..5c9ec898d 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -9,8 +9,11 @@ type FieldInfo struct { IsRole bool Numeric bool Boolean bool + // Nullable: isMissing/isPresent are supported on this column field. For numeric/boolean + // fields, missing means NULL; for string fields it means NULL or empty string. + Nullable bool - tagAlias string // If set, a tag name from mappings.yml that resolves to this field + tagAlias string // If set, a tag name from mappings.yaml that resolves to this field name string // Canonical name, populated by LookupField from the map key } @@ -21,7 +24,7 @@ func (f FieldInfo) Name() string { var fieldMap = map[string]FieldInfo{ "title": {}, - "album": {}, + "album": {Nullable: true}, "hascoverart": {Boolean: true}, "tracknumber": {}, "discnumber": {}, @@ -34,26 +37,26 @@ var fieldMap = map[string]FieldInfo{ "size": {}, "compilation": {Boolean: true}, "missing": {Boolean: true}, - "explicitstatus": {}, + "explicitstatus": {Nullable: true}, "dateadded": {}, "datemodified": {}, - "discsubtitle": {}, - "comment": {}, - "lyrics": {}, - "sorttitle": {}, - "sortalbum": {}, - "sortartist": {}, - "sortalbumartist": {}, - "albumcomment": {}, - "catalognumber": {}, + "discsubtitle": {Nullable: true}, + "comment": {Nullable: true}, + "lyrics": {Nullable: true}, + "sorttitle": {Nullable: true}, + "sortalbum": {Nullable: true}, + "sortartist": {Nullable: true}, + "sortalbumartist": {Nullable: true}, + "albumcomment": {Nullable: true}, + "catalognumber": {Nullable: true}, "filepath": {}, "filetype": {}, "codec": {}, "duration": {}, "bitrate": {}, - "bitdepth": {}, + "bitdepth": {Numeric: true, Nullable: true}, "samplerate": {}, - "bpm": {}, + "bpm": {Numeric: true, Nullable: true}, "channels": {}, "loved": {Boolean: true}, "dateloved": {}, @@ -74,21 +77,29 @@ var fieldMap = map[string]FieldInfo{ "artistlastplayed": {}, "artistdateloved": {}, "artistdaterated": {}, - "mbz_album_id": {}, - "mbz_album_artist_id": {}, - "mbz_artist_id": {}, - "mbz_recording_id": {}, - "mbz_release_track_id": {}, - "mbz_release_group_id": {}, - "rgalbumgain": {Numeric: true}, - "rgalbumpeak": {Numeric: true}, - "rgtrackgain": {Numeric: true}, - "rgtrackpeak": {Numeric: true}, + "mbz_album_id": {Nullable: true}, + "mbz_album_artist_id": {Nullable: true}, + "mbz_artist_id": {Nullable: true}, + "mbz_recording_id": {Nullable: true}, + "mbz_release_track_id": {Nullable: true}, + "mbz_release_group_id": {Nullable: true}, + "rgalbumgain": {Numeric: true, Nullable: true}, + "rgalbumpeak": {Numeric: true, Nullable: true}, + "rgtrackgain": {Numeric: true, Nullable: true}, + "rgtrackpeak": {Numeric: true, Nullable: true}, "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Backward compatibility: the replaygain_* tag names (as written in metadata and in the + // PR #5256 example) are aliases for the canonical rg* column fields. Without these, the tag + // names would be registered as empty tags from mappings.yaml and isMissing would always match. + "replaygain_album_gain": {Alias: "rgalbumgain", Numeric: true, Nullable: true}, + "replaygain_album_peak": {Alias: "rgalbumpeak", Numeric: true, Nullable: true}, + "replaygain_track_gain": {Alias: "rgtrackgain", Numeric: true, Nullable: true}, + "replaygain_track_peak": {Alias: "rgtrackpeak", Numeric: true, Nullable: true}, + // Pseudo-field for random sorting "random": {}, } @@ -128,7 +139,7 @@ func AddRoles(roles []string) { } } -// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` +// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml` // configuration file. func AddTagNames(tagNames []string) { for _, tagName := range tagNames { diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 5b6f53341..2367101a8 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -53,5 +53,39 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) + It("marks ReplayGain column fields as nullable", func() { + field, ok := LookupField("rgAlbumGain") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) + gomega.Expect(field.Nullable).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeFalse()) + }) + + It("resolves replaygain_* tag names as aliases to nullable column fields", func() { + // AddTagNames skips names already in the field map, so the startup tag registration + // (from mappings.yaml) must not convert the pre-registered alias into a tag field. + AddTagNames([]string{"replaygain_album_gain"}) + + field, ok := LookupField("replaygain_album_gain") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) + gomega.Expect(field.Nullable).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeFalse()) + }) + + It("marks mbz_* and lyrics string fields as nullable (empty means missing)", func() { + for _, name := range []string{"mbz_album_id", "mbz_album_artist_id", "mbz_artist_id", + "mbz_recording_id", "mbz_release_track_id", "mbz_release_group_id", "lyrics", + "album", "comment", "catalognumber", "discsubtitle", "albumcomment", + "sorttitle", "sortalbum", "sortartist", "sortalbumartist", "explicitstatus"} { + field, ok := LookupField(name) + gomega.Expect(ok).To(gomega.BeTrue(), name) + gomega.Expect(field.Nullable).To(gomega.BeTrue(), name) + gomega.Expect(field.Numeric).To(gomega.BeFalse(), name) + } + }) + }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index ca47ceb95..d0f453524 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -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) @@ -96,20 +110,32 @@ func normalizeBoolFields(m map[string]any) { } } -func normalizeBoolValue(v any) any { +// ToBool coerces a criteria value to a bool, accepting the forms criteria values take: a real bool, +// a strconv.ParseBool-parseable string, or a JSON number that is exactly 0 or 1. Any other value +// (other numbers, slices, nil, unparseable strings) returns ok=false so callers can handle it. +func ToBool(v any) (bool, bool) { switch val := v.(type) { + case bool: + return val, true case string: - if b, err := strconv.ParseBool(val); err == nil { - return b - } + b, err := strconv.ParseBool(val) + return b, err == nil case float64: - if val == 1 { - return true - } - if val == 0 { - return false + switch val { + case 1: + return true, true + case 0: + return false, true } } + return false, false +} + +// normalizeBoolValue leaves non-boolean values unchanged so they flow through to their own validation. +func normalizeBoolValue(v any) any { + if b, ok := ToBool(v); ok { + return b + } return v } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 3ddd77f8b..14a02ff4b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,7 +1,5 @@ package criteria -import "time" - // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { ChildPlaylistIds() []string @@ -142,10 +140,6 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { func (nitl NotInTheLast) fields() map[string]any { return nitl } -func startOfPeriod(numDays int64, from time.Time) string { - return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") -} - type InPlaylist map[string]any func (ipl InPlaylist) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 17c4272ba..e5c8e1763 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -126,4 +126,25 @@ var _ = Describe("Operators", func() { gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) }) }) + + DescribeTable("ToBool", + func(in any, wantVal, wantOk bool) { + got, ok := ToBool(in) + gomega.Expect(ok).To(gomega.Equal(wantOk)) + gomega.Expect(got).To(gomega.Equal(wantVal)) + }, + Entry("real bool true", true, true, true), + Entry("real bool false", false, false, true), + Entry("string true", "true", true, true), + Entry("string false", "false", false, true), + Entry("string 1", "1", true, true), + Entry("string t", "t", true, true), + Entry("string 0", "0", false, true), + Entry("string unparseable", "yes", false, false), + Entry("float64 1", float64(1), true, true), + Entry("float64 0", float64(0), false, true), + Entry("float64 other", float64(2), false, false), + Entry("slice", []any{true}, false, false), + Entry("nil", nil, false, false), + ) }) diff --git a/model/folder.go b/model/folder.go index 7a769735e..81800c072 100644 --- a/model/folder.go +++ b/model/folder.go @@ -86,7 +86,14 @@ type FolderRepository interface { GetAll(...QueryOptions) ([]Folder, error) CountAll(...QueryOptions) (int64, error) GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) + // HasAudioOutsideFolders reports whether any folder in parent's subtree + // (including parent itself) contains audio files and is not one of the + // given folder IDs. + HasAudioOutsideFolders(parent Folder, excludeFolderIDs []string) (bool, error) Put(*Folder) error MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) + // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring + // the scan-timestamp gate used by GetTouchedWithPlaylists. + GetAllWithPlaylists() (FolderCursor, error) } diff --git a/model/image.go b/model/image.go index 68d8ae64c..30307fcea 100644 --- a/model/image.go +++ b/model/image.go @@ -13,5 +13,5 @@ func UploadedImagePath(entityType, filename string) string { if filename == "" { return "" } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) + return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename) } diff --git a/model/lyrics.go b/model/lyrics.go index f75f3b11b..111b1c2a9 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -1,229 +1,93 @@ package model import ( - "cmp" - "regexp" - "slices" - "strconv" + "encoding/json" "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/str" ) +type Cue struct { + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` + Value string `structs:"value" json:"value"` + ByteStart int `structs:"byteStart" json:"byteStart"` + ByteEnd int `structs:"byteEnd" json:"byteEnd"` + AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"` +} + +type Agent struct { + ID string `structs:"id" json:"id"` + Role string `structs:"role" json:"role"` + Name string `structs:"name,omitempty" json:"name,omitempty"` +} + type Line struct { Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` Value string `structs:"value" json:"value"` + Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` } type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Lang string `structs:"lang" json:"lang"` - Line []Line `structs:"line" json:"line"` - Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` - Synced bool `structs:"synced" json:"synced"` + DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` + Kind string `structs:"kind,omitempty" json:"kind,omitempty"` + Lang string `structs:"lang" json:"lang"` + Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` + Line []Line `structs:"line" json:"line"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Synced bool `structs:"synced" json:"synced"` } -// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]` - -var ( - // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) +// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are +// the canonical wire values; keep them in sync with the spec. +const ( + LyricKindMain = "main" + LyricKindTranslation = "translation" + LyricKindPronunciation = "pronunciation" ) func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } -func ToLyrics(language, text string) (*Lyrics, error) { - text = str.SanitizeText(text) - - lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) - - artist := "" - title := "" - var offset *int64 = nil - - synced := syncRegex.MatchString(text) - priorLine := "" - validLine := false - repeated := false - var timestamps []int64 - - for _, line := range lines { - line := strings.TrimSpace(line) - if line == "" { - if validLine { - priorLine += "\n" - } - continue - } - var text string - var time *int64 = nil - - if synced { - idTag := lrcIdRegex.FindStringSubmatch(line) - if idTag != nil { - switch idTag[1] { - case "ar": - artist = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "lang": - language = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "offset": - { - off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) - if err != nil { - log.Warn("Error parsing offset", "offset", idTag[2], "error", err) - } else { - offset = &off - } - } - case "ti": - title = str.SanitizeText(strings.TrimSpace(idTag[2])) - } - - continue - } - - times := timeRegex.FindAllStringSubmatchIndex(line, -1) - if len(times) > 1 { - repeated = true - } - - // The second condition is for when there is a timestamp in the middle of - // a line (after any text) - if times == nil || times[0][0] != 0 { - if validLine { - priorLine += "\n" + line - } - continue - } - - if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } - timestamps = nil - } - - end := 0 - - // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] - for _, match := range times { - // for multiple matches, we need to check that later matches are not - // in the middle of the string - if end != 0 { - middle := strings.TrimSpace(line[end:match[0]]) - if middle != "" { - break - } - } - - end = match[1] - timeInMillis, err := parseTime(line, match) - if err != nil { - return nil, err - } - - timestamps = append(timestamps, timeInMillis) - } - - if end >= len(line) { - priorLine = "" - } else { - priorLine = strings.TrimSpace(line[end:]) - } - - validLine = true - } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, - }) - } - } - - if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } - } - - // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time - if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { - return cmp.Compare(*a.Start, *b.Start) - }) - } - - lyrics := Lyrics{ - DisplayArtist: artist, - DisplayTitle: title, - Lang: language, - Line: structuredLines, - Offset: offset, - Synced: synced, - } - return &lyrics, nil +// IsMainKind reports whether the lyric is the main track. A blank kind is an +// untyped (single-track) lyric, which the contract treats as main. +func (l Lyrics) IsMainKind() bool { + return l.EffectiveKind() == LyricKindMain } -func parseTime(line string, match []int) (int64, error) { - var hours, millis int64 - var err error - - hourStart := match[2] - if hourStart != -1 { - // subtract 1 because group has : at the end - hourEnd := match[3] - 1 - hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) - if err != nil { - return 0, err - } +// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank. +// A blank kind means an untyped (single-track) lyric, which the contract treats +// as main. +func (l Lyrics) EffectiveKind() string { + if strings.TrimSpace(l.Kind) == "" { + return LyricKindMain } - - minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) - if err != nil { - return 0, err - } - - sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) - if err != nil { - return 0, err - } - - msStart := match[8] - if msStart != -1 { - msEnd := match[9] - // +1 offset since this capture group contains . - millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) - if err != nil { - return 0, err - } - - length := msEnd - msStart - - if length == 3 { - millis *= 10 - } else if length == 2 { - millis *= 100 - } - } - - timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis - return timeInMillis, nil + return l.Kind } type LyricList []Lyrics + +// MarshalJSON keeps the lyrics column invariant: empty/nil serializes to [], never null. +func (ll LyricList) MarshalJSON() ([]byte, error) { + if len(ll) == 0 { + return []byte("[]"), nil + } + return json.Marshal([]Lyrics(ll)) +} + +// Main returns the main-kind lyric, falling back to the first entry so untyped +// lyrics still resolve. The bool is false only when the list is empty. It is +// used to surface a single lyric through the plain-text legacy getLyrics +// endpoint, which has no notion of translation/pronunciation tracks. +func (ll LyricList) Main() (Lyrics, bool) { + if len(ll) == 0 { + return Lyrics{}, false + } + for _, l := range ll { + if l.IsMainKind() { + return l, true + } + } + return ll[0], true +} diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go new file mode 100644 index 000000000..5a7d7871e --- /dev/null +++ b/model/lyrics_benchmark_test.go @@ -0,0 +1,45 @@ +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// Benchmark payloads are real public-domain lyrics ("Auld Lang Syne", Robert +// Burns, 1788) rendered into every supported format, so the numbers reflect +// realistic content and sizing. The same song across formats makes per-format +// cost directly comparable. Fixtures live in tests/fixtures/lyrics/. +func loadLyricFixture(b *testing.B, name string) []byte { + b.Helper() + contents, err := os.ReadFile(filepath.Join("..", "tests", "fixtures", "lyrics", name)) + if err != nil { + b.Fatal(err) + } + return contents +} + +func benchmarkParse(b *testing.B, suffix, fixture string) { + contents := loadLyricFixture(b, fixture) + b.ReportAllocs() + b.SetBytes(int64(len(contents))) + for b.Loop() { + if _, err := ParseLyrics(b.Context(), suffix, "eng", contents); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkParseLyrics_LRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_Plain(b *testing.B) { benchmarkParse(b, ".txt", "auld-lang-syne.txt") } +func BenchmarkParseLyrics_EnhancedLRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.elrc") } +func BenchmarkParseLyrics_SRT(b *testing.B) { benchmarkParse(b, ".srt", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_TTML(b *testing.B) { benchmarkParse(b, ".ttml", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_YAML(b *testing.B) { benchmarkParse(b, ".yaml", "auld-lang-syne.yaml") } + +// Content-sniff path (empty suffix) — what embedded tags and plugins hit. +func BenchmarkParseLyrics_SniffTTML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_SniffSRT(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_SniffYAML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.yaml") } +func BenchmarkParseLyrics_SniffLRC(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_SniffPlain(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.txt") } diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go new file mode 100644 index 000000000..2cccb9d51 --- /dev/null +++ b/model/lyrics_lrc.go @@ -0,0 +1,358 @@ +package model + +import ( + "cmp" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" +) + +// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] +const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` + +var ( + // Should either be at the beginning of file, or beginning of line + syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) + timeRegex = regexp.MustCompile(timeRegexString) + lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) +) + +func parseLRC(language, text string) (*Lyrics, error) { + text = str.SanitizeText(text) + + lines := strings.Split(text, "\n") + structuredLines := make([]Line, 0, len(lines)*2) + + artist := "" + title := "" + var offset *int64 = nil + + synced := syncRegex.MatchString(text) + priorLine := "" + validLine := false + repeated := false + var timestamps []int64 + + for _, line := range lines { + line := strings.TrimSpace(line) + if line == "" { + if validLine { + priorLine += "\n" + } + continue + } + var text string + var time *int64 = nil + + if synced { + idTag := lrcIdRegex.FindStringSubmatch(line) + if idTag != nil { + switch idTag[1] { + case "ar": + artist = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "lang": + language = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "offset": + { + off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) + if err != nil { + log.Warn("Error parsing offset", "offset", idTag[2], "error", err) + } else { + offset = &off + } + } + case "ti": + title = str.SanitizeText(strings.TrimSpace(idTag[2])) + } + + continue + } + + times := timeRegex.FindAllStringSubmatchIndex(line, -1) + if len(times) > 1 { + repeated = true + } + + // The second condition is for when there is a timestamp in the middle of + // a line (after any text) + if len(times) == 0 || times[0][0] != 0 { + if validLine { + priorLine += "\n" + line + } + continue + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + } + + end := 0 + + // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] + for _, match := range times { + // for multiple matches, we need to check that later matches are not + // in the middle of the string + if end != 0 { + middle := strings.TrimSpace(line[end:match[0]]) + if middle != "" { + break + } + } + + end = match[1] + timeInMillis, err := parseTime(line, match) + if err != nil { + return nil, err + } + + timestamps = append(timestamps, timeInMillis) + } + + if end >= len(line) { + priorLine = "" + } else { + priorLine = strings.TrimSpace(line[end:]) + } + + validLine = true + } else { + text = line + structuredLines = append(structuredLines, Line{ + Start: time, + Value: text, + }) + } + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + } + + // If there are repeated values, there is no guarantee that they are in order + // In this, case, sort the lyrics by start time + if repeated { + slices.SortFunc(structuredLines, func(a, b Line) int { + return cmp.Compare(*a.Start, *b.Start) + }) + } + + lyrics := Lyrics{ + DisplayArtist: artist, + DisplayTitle: title, + Lang: language, + Line: normalizeCueLines(structuredLines), + Offset: offset, + Synced: synced, + } + return &lyrics, nil +} + +// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers +// and computes UTF-8 byte offsets against the final stripped line value. +func parseEnhancedLine(text string) (string, []Cue) { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return strings.TrimSpace(text), nil + } + + type segment struct { + start int64 + rawStart int + rawEnd int + } + + segments := make([]segment, 0, len(matches)) + var rawValue strings.Builder + var trailingEnd *int64 + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + if i == len(matches)-1 { + trailingEnd = &timeMs + } + continue + } + + rawStart := rawValue.Len() + rawValue.WriteString(word) + segments = append(segments, segment{ + start: timeMs, + rawStart: rawStart, + rawEnd: rawValue.Len(), + }) + } + + if len(segments) == 0 { + return strings.TrimSpace(stripEnhancedMarkers(text)), nil + } + + finalRaw := rawValue.String() + leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) + rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) + trimmedEnd := len(finalRaw) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + cues := make([]Cue, 0, len(segments)) + for _, seg := range segments { + start := seg.start + byteStart := max(seg.rawStart, leftTrimBytes) + byteEnd := min(seg.rawEnd, trimmedEnd) + if byteStart >= byteEnd { + continue + } + + cues = append(cues, Cue{ + Start: &start, + Value: finalRaw[byteStart:byteEnd], + ByteStart: byteStart - leftTrimBytes, + ByteEnd: byteEnd - leftTrimBytes - 1, + }) + } + + if trailingEnd != nil && len(cues) > 0 { + cues[len(cues)-1].End = trailingEnd + } + + return strings.TrimSpace(finalRaw), cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + +func parseTime(line string, match []int) (int64, error) { + var hours, millis int64 + var err error + + hourStart := match[2] + if hourStart != -1 { + // subtract 1 because group has : at the end + hourEnd := match[3] - 1 + hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) + if err != nil { + return 0, err + } + } + + minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) + if err != nil { + return 0, err + } + + sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) + if err != nil { + return 0, err + } + + msStart := match[8] + if msStart != -1 { + msEnd := match[9] + // +1 offset since this capture group contains . + millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) + if err != nil { + return 0, err + } + + length := msEnd - msStart + + if length == 3 { + millis *= 10 + } else if length == 2 { + millis *= 100 + } + } + + timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis + return timeInMillis, nil +} diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go new file mode 100644 index 000000000..87514caf1 --- /dev/null +++ b/model/lyrics_lrc_test.go @@ -0,0 +1,255 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLRC", func() { + It("should parse tags with spaces", func() { + lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Lang).To(Equal("eng")) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.DisplayArtist).To(Equal("An artist")) + Expect(lyrics.DisplayTitle).To(Equal("A title")) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) + }) + + It("Should ignore bad offset", func() { + lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Offset).To(BeNil()) + }) + + It("should accept lines with no text and weird times", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, + })) + }) + + It("Should support multiple timestamps per line", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, + })) + }) + + It("Should support parsing multiline string", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, + })) + }) + + It("Does not match timestamp in middle of line", func() { + lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeFalse()) + Expect(lyrics.Line).To(Equal([]Line{ + {Value: "This could [00:00:00] be a synced file"}, + })) + }) + + It("Allows timestamp in middle of line if also at beginning", func() { + lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, + })) + }) + + It("Ignores lines in synchronized lyric prior to first timestamp", func() { + lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Text"}, + })) + }) + + It("Handles all possible ms cases", func() { + lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, + })) + }) + + It("Properly sorts repeated lyrics out of order", func() { + lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, + })) + }) + + It("should parse Enhanced LRC with word-level timing", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, + {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + + Expect(line1.Cue[1].End).To(BeNil()) + }) + + It("should not parse malformed Enhanced LRC timing markers", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(Equal([]Line{ + {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(3)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) + + It("should preserve byte offsets for Enhanced LRC cues", func() { + lyrics, err := parseLRC("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(1)) + + t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Oh love me tonight")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, + {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, + {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, + {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, + })) + }) + + It("should use a trailing Enhanced LRC marker as the end of the last word", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics<00:02.00>\n[00:30.00]Instrumental over") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t2000 := int64(1000), int64(1500), int64(2000) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Some lyrics")) + Expect(line.End).To(Equal(&t2000)) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + }) + + It("should shift a trailing Enhanced LRC marker for repeated line occurrences", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world<00:10.90>") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10100, t10500, t10900 := int64(10100), int64(10500), int64(10900) + t30100, t30500, t30900 := int64(30100), int64(30500), int64(30900) + + Expect(lyrics.Line[0].End).To(Equal(&t10900)) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t10900, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].End).To(Equal(&t30900)) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, End: &t30500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, End: &t30900, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Line).To(HaveLen(2)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) +}) diff --git a/model/lyrics_lyricsfile.go b/model/lyrics_lyricsfile.go new file mode 100644 index 000000000..49d416f3f --- /dev/null +++ b/model/lyrics_lyricsfile.go @@ -0,0 +1,283 @@ +package model + +import ( + "bytes" + "fmt" + "strings" + + "github.com/navidrome/navidrome/utils/str" + "gopkg.in/yaml.v3" +) + +// parseLyricsfile parses a LRCLIB Lyricsfile YAML document +// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) +// into a model.LyricList containing a single main Lyrics entry. Returns +// (nil, nil) when the input parses as YAML but does not declare Lyricsfile +// version 1.0. +// +// When the source contains per-word timing via lines[].words[], each word +// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and +// overlapping lines are attributed to synthetic voice agents via lowest-free +// voice ID assignment so the OpenSubsonic v2 enhanced response can split +// parallel vocals. +func parseLyricsfile(lang string, contents []byte) (LyricList, error) { + var doc lyricsfileDocument + dec := yaml.NewDecoder(bytes.NewReader(contents)) + dec.KnownFields(false) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) + } + + if strings.TrimSpace(doc.Version) != lyricsfileVersion { + return nil, nil + } + + // Fall back to the caller's language when the document omits its own, matching + // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. + docLang := doc.Metadata.Language + if strings.TrimSpace(docLang) == "" { + docLang = lang + } + lyrics := Lyrics{ + DisplayArtist: str.SanitizeText(doc.Metadata.Artist), + DisplayTitle: str.SanitizeText(doc.Metadata.Title), + Lang: normalizeLyricLang(docLang), + Kind: LyricKindMain, + } + if doc.Metadata.OffsetMs != 0 { + off := doc.Metadata.OffsetMs + lyrics.Offset = &off + } + + if doc.Metadata.Instrumental { + return LyricList{normalizeLyrics(lyrics)}, nil + } + + if len(doc.Lines) == 0 { + lines := buildPlainLyricsfileLines(doc.Plain) + if len(lines) == 0 { + return nil, nil + } + lyrics.Line = lines + return LyricList{normalizeLyrics(lyrics)}, nil + } + + lines, agents := buildLyricsfileLines(doc.Lines) + lyrics.Line = lines + lyrics.Agents = agents + lyrics.Synced = true + return LyricList{normalizeLyrics(lyrics)}, nil +} + +const lyricsfileVersion = "1.0" + +type lyricsfileDocument struct { + Version string `yaml:"version"` + Metadata lyricsfileMetadata `yaml:"metadata"` + Lines []lyricsfileLineEntry `yaml:"lines"` + Plain string `yaml:"plain"` +} + +type lyricsfileMetadata struct { + Title string `yaml:"title"` + Artist string `yaml:"artist"` + Album string `yaml:"album"` + DurationMs int64 `yaml:"duration_ms"` + OffsetMs int64 `yaml:"offset_ms"` + Language string `yaml:"language"` + Instrumental bool `yaml:"instrumental"` +} + +type lyricsfileLineEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` + Words []lyricsfileWordEntry `yaml:"words"` +} + +type lyricsfileWordEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` +} + +// buildLyricsfileLines converts YAML line entries to model.Line entries with +// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free +// voice ID). The Agents slice is emitted only when at least one cue carries +// attribution AND more than one voice is used; otherwise AgentIDs are +// stripped so the wire shape stays simple per the OpenSubsonic spec rule +// "agents should not be emitted without cueLine data". +func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { + if len(entries) == 0 { + return nil, nil + } + + // Resolved end timestamps per entry: explicit end_ms, final word end_ms, + // then the next entry's start. The last entry's end stays nil when no + // explicit or word-level end is available. + ends := make([]*int64, len(entries)) + for i := range entries { + var nextStart *int64 + if i+1 < len(entries) { + v := entries[i+1].StartMs + nextStart = &v + } + ends[i] = lyricsfileLineEnd(entries[i], nextStart) + } + + active := map[int]int64{} + maxVoice := -1 + anyCues := false + lines := make([]Line, 0, len(entries)) + + for i, entry := range entries { + for vID, vEnd := range active { + if vEnd <= entry.StartMs { + delete(active, vID) + } + } + + voiceID := 0 + for { + if _, busy := active[voiceID]; !busy { + break + } + voiceID++ + } + if voiceID > maxVoice { + maxVoice = voiceID + } + + agentID := fmt.Sprintf("voice-%d", voiceID) + cues, value := wordsToLineCues(entry, agentID) + if len(cues) > 0 { + anyCues = true + } + + startMs := entry.StartMs + line := Line{ + Start: &startMs, + End: ends[i], + Value: value, + Cue: cues, + } + lines = append(lines, line) + + var endMs int64 + if ends[i] != nil { + endMs = *ends[i] + } else { + endMs = entry.StartMs + } + active[voiceID] = endMs + } + + // Monophonic source, or attribution that has nowhere to land: emit no + // agents and strip per-cue AgentIDs to keep the wire shape simple. + if maxVoice <= 0 || !anyCues { + for i := range lines { + for j := range lines[i].Cue { + lines[i].Cue[j].AgentID = "" + } + } + return lines, nil + } + + agents := make([]Agent, 0, maxVoice+1) + for v := 0; v <= maxVoice; v++ { + role := "voice" + if v == 0 { + role = "main" + } + agents = append(agents, Agent{ + ID: fmt.Sprintf("voice-%d", v), + Role: role, + }) + } + return lines, agents +} + +func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { + if entry.EndMs != nil { + v := *entry.EndMs + return &v + } + if len(entry.Words) > 0 { + lastWord := entry.Words[len(entry.Words)-1] + if lastWord.EndMs != nil { + v := *lastWord.EndMs + return &v + } + } + if nextStart != nil { + v := *nextStart + return &v + } + return nil +} + +func buildPlainLyricsfileLines(plain string) []Line { + plain = str.SanitizeText(plain) + rawLines := strings.Split(plain, "\n") + lines := make([]Line, 0, len(rawLines)) + for _, raw := range rawLines { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + lines = append(lines, Line{Value: value}) + } + return lines +} + +// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue +// entries with inclusive UTF-8 byte offsets into the reconstructed line +// value. The line value is built from cue text concatenation rather than +// trusting entry.Text, because the Lyricsfile spec only requires word.text +// to "approximate" line.text - byte offsets must always land inside +// Line.Value. +func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { + if len(entry.Words) == 0 { + return nil, str.SanitizeText(entry.Text) + } + + var sb strings.Builder + for _, w := range entry.Words { + sb.WriteString(w.Text) + } + lineValue := sb.String() + + cues := make([]Cue, len(entry.Words)) + cursor := 0 + for i, w := range entry.Words { + valueBytes := len(w.Text) + bs := cursor + be := bs + if valueBytes > 0 { + be = bs + valueBytes - 1 + cursor = be + 1 + } + + s := w.StartMs + cue := Cue{ + Start: &s, + Value: w.Text, + ByteStart: bs, + ByteEnd: be, + AgentID: agentID, + } + if w.EndMs != nil { + e := *w.EndMs + cue.End = &e + } + cues[i] = cue + } + + for i := 0; i < len(cues)-1; i++ { + if cues[i].End == nil && cues[i+1].Start != nil { + v := *cues[i+1].Start + cues[i].End = &v + } + } + return cues, lineValue +} diff --git a/model/lyrics_lyricsfile_test.go b/model/lyrics_lyricsfile_test.go new file mode 100644 index 000000000..45899cda7 --- /dev/null +++ b/model/lyrics_lyricsfile_test.go @@ -0,0 +1,300 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLyricsfile", func() { + DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", + func(input string) { + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(BeNil()) + }, + Entry("arbitrary YAML", "hello: world\n"), + Entry("Lyricsfile-shaped but unversioned", `metadata: + title: 'Looks close' +lines: + - text: "But should not be claimed" + start_ms: 1000 +`), + ) + + It("returns an error for invalid YAML", func() { + _, err := parseLyricsfile("", []byte("not: valid: yaml: [")) + Expect(err).To(HaveOccurred()) + }) + + It("parses line-level metadata without cues", func() { + input := `version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Test Artist")) + Expect(l.DisplayTitle).To(Equal("Sample Track")) + Expect(l.Synced).To(BeTrue()) + Expect(l.Offset).ToNot(BeNil()) + Expect(*l.Offset).To(Equal(int64(-100))) + Expect(l.Agents).To(BeNil()) + + Expect(l.Line).To(HaveLen(2)) + Expect(*l.Line[0].Start).To(Equal(int64(18800))) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(22801))) + Expect(l.Line[0].Value).To(Equal("We're no strangers to love")) + Expect(l.Line[0].Cue).To(BeNil()) + + Expect(*l.Line[1].Start).To(Equal(int64(22801))) + Expect(l.Line[1].End).To(BeNil()) + Expect(l.Line[1].Value).To(Equal("You know the rules and so do I")) + Expect(l.Line[1].Cue).To(BeNil()) + }) + + DescribeTable("resolves the lyric language", + func(metaLanguage, callerLang, want string) { + input := "version: '1.0'\nmetadata:\n title: 'T'\n" + if metaLanguage != "" { + input += " language: '" + metaLanguage + "'\n" + } + input += "lines:\n - text: \"line\"\n start_ms: 0\n" + + lyrics, err := parseLyricsfile(callerLang, []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Lang).To(Equal(want)) + }, + Entry("prefers the document's own language", "eng", "deu", "eng"), + Entry("falls back to the caller language when metadata omits it", "", "deu", "deu"), + Entry("uses xxx when neither is provided", "", "", "xxx"), + ) + + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { + input := `version: '1.0' +metadata: + title: 'Plain Track' + artist: 'Plain Artist' + language: 'en' +lines: [] +plain: | + [Verse 1] + First line + + Second line +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("en")) + Expect(l.DisplayArtist).To(Equal("Plain Artist")) + Expect(l.DisplayTitle).To(Equal("Plain Track")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(Equal([]Line{ + {Value: "[Verse 1]"}, + {Value: "First line"}, + {Value: "Second line"}, + })) + }) + + It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { + input := `version: '1.0' +metadata: + title: 'Karaoke' + artist: 'Singer' + language: 'eng' +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Synced).To(BeTrue()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(HaveLen(1)) + + line := l.Line[0] + Expect(*line.Start).To(Equal(int64(1000))) + Expect(*line.End).To(Equal(int64(3000))) + Expect(line.Value).To(Equal("Hello world")) + Expect(line.Cue).To(HaveLen(2)) + + Expect(*line.Cue[0].Start).To(Equal(int64(1000))) + Expect(*line.Cue[0].End).To(Equal(int64(1500))) + Expect(line.Cue[0].Value).To(Equal("Hello ")) + Expect(line.Cue[0].ByteStart).To(Equal(0)) + Expect(line.Cue[0].ByteEnd).To(Equal(5)) + Expect(line.Cue[0].AgentID).To(Equal("")) + + Expect(*line.Cue[1].Start).To(Equal(int64(1500))) + Expect(*line.Cue[1].End).To(Equal(int64(3000))) + Expect(line.Cue[1].Value).To(Equal("world")) + Expect(line.Cue[1].ByteStart).To(Equal(6)) + Expect(line.Cue[1].ByteEnd).To(Equal(10)) + Expect(line.Cue[1].AgentID).To(Equal("")) + }) + + It("prefers final word end_ms over next line start when inferring line end", func() { + input := `version: '1.0' +metadata: + title: 'Overlap From Words' +lines: + - text: "Long vocal" + start_ms: 1000 + words: + - text: "Long " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 3000 + end_ms: 3500 + words: + - text: "echo" + start_ms: 3000 + end_ms: 3500 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + }) + + It("synthesises voice agents for overlapping lines and attributes per-cue", func() { + input := `version: '1.0' +metadata: + title: 'Duet' +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + + Expect(l.Line[0].Value).To(Equal("Lead vocal")) + Expect(*l.Line[0].Start).To(Equal(int64(1000))) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue).To(HaveLen(2)) + Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0")) + Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4)) + Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5)) + Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9)) + + Expect(l.Line[1].Value).To(Equal("echo")) + Expect(*l.Line[1].Start).To(Equal(int64(2000))) + Expect(*l.Line[1].End).To(Equal(int64(3000))) + Expect(l.Line[1].Cue).To(HaveLen(1)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0)) + Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3)) + }) + + It("emits empty lines with Synced=false for instrumental tracks", func() { + input := `version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("eng")) + Expect(l.DisplayArtist).To(Equal("Composer")) + Expect(l.DisplayTitle).To(Equal("Solo Piano")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Line).To(BeEmpty()) + Expect(l.Agents).To(BeNil()) + }) + + It("strips agent attribution when overlapping lines carry no cues", func() { + input := `version: '1.0' +lines: + - text: "Lead" + start_ms: 1000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 +` + lyrics, err := parseLyricsfile("", []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Line).To(HaveLen(2)) + Expect(l.Agents).To(BeNil()) + Expect(l.Line[0].Cue).To(BeNil()) + Expect(l.Line[1].Cue).To(BeNil()) + }) +}) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go new file mode 100644 index 000000000..a7d4b4e2a --- /dev/null +++ b/model/lyrics_normalize.go @@ -0,0 +1,171 @@ +package model + +import ( + "slices" + + "github.com/navidrome/navidrome/utils/gg" +) + +func normalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line = normalizeCueLines(lyrics.Line) + if len(lyrics.Agents) == 0 { + lyrics.Agents = nil + } + return lyrics +} + +func normalizeCueLines(lines []Line) []Line { + if len(lines) == 0 { + return lines + } + + normalized := make([]Line, len(lines)) + copy(normalized, lines) + + for i := range normalized { + if len(normalized[i].Cue) > 0 { + normalized[i].Cue = slices.Clone(normalized[i].Cue) + } + + var fallbackEnd *int64 + if normalized[i].End != nil { + v := *normalized[i].End + fallbackEnd = &v + } else if i+1 < len(normalized) && normalized[i+1].Start != nil { + v := *normalized[i+1].Start + fallbackEnd = &v + } + + normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) + } + + return normalized +} + +func normalizeLineTiming(line Line) Line { + if len(line.Cue) == 0 { + return line + } + + var earliestStart *int64 + var latestEnd *int64 + for i := range line.Cue { + token := line.Cue[i] + if token.Start != nil { + if earliestStart == nil || *token.Start < *earliestStart { + v := *token.Start + earliestStart = &v + } + } + + candidateEnd := token.End + if candidateEnd == nil { + candidateEnd = token.Start + } + if candidateEnd != nil { + if latestEnd == nil || *candidateEnd > *latestEnd { + v := *candidateEnd + latestEnd = &v + } + } + } + + if line.Start == nil && earliestStart != nil { + v := *earliestStart + line.Start = &v + } + if line.End == nil && latestEnd != nil { + v := *latestEnd + line.End = &v + } + return line +} + +func normalizeCueLine(line Line, fallbackEnd *int64) Line { + if len(line.Cue) == 0 { + return line + } + line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd) + return normalizeLineTiming(line) +} + +// normalizeCueEndsByAgent resolves cue end times independently per agent so that +// background (or other parallel) layers, whose cues interleave with the main +// timeline but are stored together in document order, do not clamp each other's +// ends. Each agent group is normalized in its own document order; results are +// reassembled into the original cue positions. +func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue { + groups := make(map[string][]int) + order := make([]string, 0, 2) + for i := range cues { + id := cues[i].AgentID + if _, ok := groups[id]; !ok { + order = append(order, id) + } + groups[id] = append(groups[id], i) + } + + // Single agent: the document order already matches the timeline, so the + // straightforward normalization applies without regrouping. + if len(order) <= 1 { + return NormalizeCueEnds(cues, fallbackEnd) + } + + out := slices.Clone(cues) + for _, id := range order { + idxs := groups[id] + group := make([]Cue, len(idxs)) + for gi, pos := range idxs { + group[gi] = cues[pos] + } + group = NormalizeCueEnds(group, fallbackEnd) + for gi, pos := range idxs { + out[pos] = group[gi] + } + } + return out +} + +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +// +// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends +// per agent group while building the response; all other normalization is +// package-internal. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues + } + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = gg.Clone(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} diff --git a/model/lyrics_normalize_test.go b/model/lyrics_normalize_test.go new file mode 100644 index 000000000..24faffd54 --- /dev/null +++ b/model/lyrics_normalize_test.go @@ -0,0 +1,120 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("normalizeCueLines", func() { + It("should not mutate caller cue slices when filling missing cue end times", func() { + start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) + lines := []Line{ + { + Start: &start0, + Value: "Some lyrics", + Cue: []Cue{ + {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + }, + }, + { + Start: &nextLineStart, + Value: "Next line", + }, + } + + normalized := normalizeCueLines(lines) + + Expect(normalized[0].Cue[0].End).To(Equal(&start1)) + Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) + Expect(lines[0].Cue[0].End).To(BeNil()) + Expect(lines[0].Cue[1].End).To(BeNil()) + }) +}) + +var _ = Describe("NormalizeCueEnds", func() { + // p returns a fresh pointer so cases don't share *int64 state. + p := func(v int64) *int64 { return &v } + + // endsOf extracts the resolved end times (nil-safe) for compact assertions. + endsOf := func(cues []Cue) []*int64 { + out := make([]*int64, len(cues)) + for i := range cues { + out[i] = cues[i].End + } + return out + } + + It("returns the input as-is when empty", func() { + Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil()) + Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty()) + }) + + It("fills a missing end from the next cue's start", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) + }) + + It("fills the last cue's missing end from fallbackEnd", func() { + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)})) + }) + + It("clamps an end that overruns the next cue's start", func() { + cues := []Cue{ + {Start: p(1000), End: p(9999)}, + {Start: p(1500), End: p(2000)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)})) + }) + + It("clamps an end that precedes the cue's own start", func() { + cues := []Cue{ + {Start: p(1000), End: p(500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1000)})) + }) + + It("clears all ends when any cue still lacks one (all-or-none)", func() { + // The last cue has no end and there is no fallback, so it stays nil and + // every end in the group is cleared. + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, nil) + + Expect(endsOf(out)).To(Equal([]*int64{nil, nil})) + }) + + It("does not mutate the input slice", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + _ = NormalizeCueEnds(cues, p(3000)) + + Expect(cues[0].End).To(BeNil()) + Expect(cues[1].End).To(BeNil()) + }) +}) diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go new file mode 100644 index 000000000..8aa095c50 --- /dev/null +++ b/model/lyrics_parse.go @@ -0,0 +1,84 @@ +package model + +import ( + "bytes" + "context" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/log" +) + +// lyricParser returns an empty list (not an error) when the input is not its +// format, so parsers can be tried in order. lang is the default for formats that +// do not carry their own. +type lyricParser func(lang string, contents []byte) (LyricList, error) + +// lyricFormats is the structured formats in content-sniff probe order; each +// row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. +var lyricFormats = []struct { + suffixes []string + parse lyricParser +}{ + {[]string{".ttml"}, parseTTML}, + {[]string{".srt"}, parseSRT}, + {[]string{".yaml", ".yml"}, parseLyricsfile}, +} + +// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes +// to that format's parser; an empty or "auto" suffix content-sniffs. Either way, +// a structured parser that does not match falls back to the LRC/plain-text floor. +// +// Parse failures are logged through ctx; callers that know the source should +// attach it for attribution, e.g. log.NewContext(ctx, "file", path). +func ParseLyrics(ctx context.Context, suffix, lang string, contents []byte) (LyricList, error) { + contents = stripBOM(contents) + suffix = strings.ToLower(suffix) + sniff := suffix == "" || suffix == "auto" + + // Sniffing tries every format in order; a known suffix selects just its own. + // Unmatched suffixes leave no candidates, so parseFirstMatch falls to plain. + candidates := make([]lyricParser, 0, len(lyricFormats)) + for _, f := range lyricFormats { + if sniff || slices.Contains(f.suffixes, suffix) { + candidates = append(candidates, f.parse) + } + } + return parseFirstMatch(ctx, sniff, lang, contents, candidates...) +} + +func parseFirstMatch(ctx context.Context, sniff bool, lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { + for _, parse := range candidates { + list, err := parse(lang, contents) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil { + // While sniffing, a probe rejecting content it does not own is expected + // control flow, so keep it at trace. A failure under an explicit suffix + // means the declared format is malformed and deserves a warning. + if sniff { + log.Trace(ctx, "Lyrics probe did not match, trying next format", err) + } else { + log.Warn(ctx, "Error parsing lyrics, falling back to plain text", err) + } + } + } + return plainLRC(lang, contents) +} + +func plainLRC(lang string, contents []byte) (LyricList, error) { + lyric, err := parseLRC(lang, string(contents)) + if err != nil { + return nil, fmt.Errorf("parsing lyrics: %w", err) + } + if lyric == nil || lyric.IsEmpty() { + return nil, nil + } + return LyricList{*lyric}, nil +} + +func stripBOM(contents []byte) []byte { + return bytes.TrimPrefix(contents, []byte("\ufeff")) +} diff --git a/model/lyrics_parse_test.go b/model/lyrics_parse_test.go new file mode 100644 index 000000000..0b47e55e9 --- /dev/null +++ b/model/lyrics_parse_test.go @@ -0,0 +1,269 @@ +package model + +import ( + "strings" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" +) + +var _ = Describe("ParseLyrics", func() { + DescribeTable("known suffix routes to the matching parser", + func(suffix, contents string, wantSynced bool, wantFirst string) { + list, err := ParseLyrics(GinkgoT().Context(), suffix, "eng", []byte(contents)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(Equal(wantSynced)) + Expect(list[0].Line[0].Value).To(Equal(wantFirst)) + }, + Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"), + Entry(".txt plain", ".txt", "plain line", false, "plain line"), + Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"), + Entry(".ttml", ".ttml", `

ttml line

`, true, "ttml line"), + Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"), + ) + + It("empty suffix content-sniffs (TTML)", func() { + ttml := `

auto ttml

` + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(ttml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto ttml")) + }) + + It("empty suffix content-sniffs (YAML)", func() { + yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n" + list, err := ParseLyrics(GinkgoT().Context(), "auto", "eng", []byte(yaml)) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("auto yaml")) + }) + + It("falls back to plain text when a known suffix fails to parse structurally", func() { + list, err := ParseLyrics(GinkgoT().Context(), ".srt", "eng", []byte("not actually an srt file")) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line[0].Value).To(Equal("not actually an srt file")) + }) + + Describe("logging on parser probe failures", func() { + var hook *test.Hook + + BeforeEach(func() { + prevLevel := log.CurrentLevel() + l, h := test.NewNullLogger() + hook = h + // Swap the logger before raising the level: SetLevel also forces the + // current default logger to logrus.TraceLevel, and the null logger would + // otherwise stay at Info and drop Trace entries before the hook sees them. + prevLogger := log.SetDefaultLogger(l) + log.SetLevel(log.LevelTrace) + DeferCleanup(func() { + log.SetDefaultLogger(prevLogger) + log.SetLevel(prevLevel) + }) + }) + + // This is the source of the full-scan log spam: embedded lyrics are parsed + // with an empty suffix (sniff mode), so every plain-text lyric fails the + // YAML/SRT/TTML probes on its way to the plain-text fallback. A probe miss + // during sniffing is expected control flow, not a warning. + It("logs sniff probe misses at trace only, with file attribution", func() { + ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.mp3") + list, err := ParseLyrics(ctx, "", "eng", []byte("Just a plain\nlyric line\n")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("Just a plain")) + entries := hook.AllEntries() + Expect(entries).ToNot(BeEmpty(), "probe misses should be observable at trace") + for _, e := range entries { + Expect(e.Level).To(Equal(logrus.TraceLevel), + "sniff-mode probe misses must not be logged above Trace") + Expect(e.Data).To(HaveKeyWithValue("file", "/music/song.mp3")) + } + }) + + // A specific suffix means the user declared the format, so a structural + // failure is worth surfacing loudly — and it must name the file. + It("warns and names the file when a requested suffix fails to parse", func() { + ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.yaml") + list, err := ParseLyrics(ctx, ".yaml", "eng", []byte("not: [valid, yaml\n")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) // still falls back to plain text + entry := hook.LastEntry() + Expect(entry).ToNot(BeNil()) + Expect(entry.Level).To(Equal(logrus.WarnLevel)) + Expect(entry.Data).To(HaveKeyWithValue("file", "/music/song.yaml")) + }) + }) +}) + +var _ = Describe("ParseLyrics content-sniffing", func() { + It("should parse embedded TTML with the tag language as the default", func() { + content := ` + + + + Lead Vocal + + + + +
+

+ Hello world +

+
+ +
` + + list, err := ParseLyrics(GinkgoT().Context(), "", "ENG", []byte(content)) + + // ParseLyrics's job is to detect TTML and apply the tag language as the + // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Hello world")) + }) + + It("should preserve embedded TTML translation and pronunciation tracks", func() { + content := ` + + + + + + Hola + + + + + konni + + + + + + +
+

こんにちは

+
+ +
` + + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + Expect(list[0].Kind).To(Equal("main")) + Expect(list[0].Lang).To(Equal("ja")) + Expect(list[0].Line[0].Value).To(Equal("こんにちは")) + Expect(list[1].Kind).To(Equal("translation")) + Expect(list[1].Lang).To(Equal("es")) + Expect(list[1].Line[0].Value).To(Equal("Hola")) + Expect(list[2].Kind).To(Equal("pronunciation")) + Expect(list[2].Lang).To(Equal("ja-latn")) + Expect(list[2].Line[0].Value).To(Equal("konni")) + Expect(list[2].Line[0].Cue).To(HaveLen(2)) + }) + + It("should parse embedded SRT with the tag language", func() { + content := `1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line` + + list, err := ParseLyrics(GinkgoT().Context(), "", "POR", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(Equal(LyricList{ + { + Lang: "por", + Line: []Line{ + { + Start: new(int64(18800)), + End: new(int64(22800)), + Value: "We're from subtitles", + }, + { + Start: new(int64(22801)), + End: new(int64(26000)), + Value: "Another subtitle line", + }, + }, + Synced: true, + }, + })) + }) + + It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { + content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" + + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("should keep embedded enhanced LRC cues", func() { + content := "[00:01.00]<00:01.00>Lead <00:01.50>words" + + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line[0].Value).To(Equal("Lead words")) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + }) + + It("should fall back to plain lyrics when embedded TTML is invalid", func() { + content := ` + +

Broken

+ +
` + + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).ToNot(BeEmpty()) + values := make([]string, 0, len(list[0].Line)) + for _, line := range list[0].Line { + values = append(values, line.Value) + } + Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) + }) + + It("detects a Lyricsfile YAML payload via content-sniffing", func() { + yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" + + list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(yaml)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("sniffed yaml line")) + }) +}) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go new file mode 100644 index 000000000..319a59961 --- /dev/null +++ b/model/lyrics_srt.go @@ -0,0 +1,158 @@ +package model + +import ( + "regexp" + "strconv" + "strings" + + "github.com/navidrome/navidrome/utils/str" +) + +var ( + srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`) + srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) +) + +func parseSRT(language string, contents []byte) (LyricList, error) { + raw := strings.ReplaceAll(string(contents), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + blocks := splitSRTBlocks(raw) + lines := make([]Line, 0, len(blocks)) + + for _, block := range blocks { + line, ok, err := parseSRTBlock(block) + if err != nil { + return nil, err + } + if ok { + lines = append(lines, line) + } + } + + if len(lines) == 0 { + return nil, nil + } + + lyrics := normalizeLyrics(Lyrics{ + Lang: normalizeLyricLang(language), + Line: lines, + Synced: true, + }) + return LyricList{lyrics}, nil +} + +func splitSRTBlocks(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + parts := srtBlockSeparatorRegex.Split(raw, -1) + blocks := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + blocks = append(blocks, part) + } + } + return blocks +} + +func parseSRTBlock(block string) (Line, bool, error) { + rawLines := strings.Split(block, "\n") + lines := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + lines = append(lines, strings.TrimSpace(line)) + } + + if len(lines) == 0 { + return Line{}, false, nil + } + + startIdx := 0 + if digitsOnly(lines[0]) { + startIdx = 1 + } + if startIdx >= len(lines) { + return Line{}, false, nil + } + + timing := strings.Split(lines[startIdx], "-->") + if len(timing) != 2 { + return Line{}, false, nil + } + + startMs, err := parseSRTTime(timing[0]) + if err != nil { + return Line{}, false, err + } + endMs, err := parseSRTTime(timing[1]) + if err != nil { + return Line{}, false, err + } + + textLines := make([]string, 0, len(lines)-startIdx-1) + for _, line := range lines[startIdx+1:] { + if line == "" { + continue + } + textLines = append(textLines, line) + } + + value := str.SanitizeText(strings.Join(textLines, "\n")) + if value == "" { + return Line{}, false, nil + } + + return Line{ + Start: &startMs, + End: &endMs, + Value: value, + }, true, nil +} + +func parseSRTTime(value string) (int64, error) { + match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return 0, strconv.ErrSyntax + } + + hours, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return 0, err + } + minutes, err := strconv.ParseInt(match[2], 10, 64) + if err != nil { + return 0, err + } + seconds, err := strconv.ParseInt(match[3], 10, 64) + if err != nil { + return 0, err + } + millis, err := strconv.ParseInt(match[4], 10, 64) + if err != nil { + return 0, err + } + + switch len(match[4]) { + case 1: + millis *= 100 + case 2: + millis *= 10 + } + + return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go new file mode 100644 index 000000000..2c0ab2242 --- /dev/null +++ b/model/lyrics_srt_test.go @@ -0,0 +1,30 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseSRT", func() { + It("parses SRT blocks with the default language", func() { + content := []byte("1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle") + + list, err := parseSRT("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("returns nil for input with no valid blocks", func() { + list, err := parseSRT("xxx", []byte("not actually an srt file")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) +}) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 382976872..fd954ad26 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,119 +1,69 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ToLyrics", func() { - It("should parse tags with spaces", func() { - num := int64(1551) - lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Lang).To(Equal("eng")) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.DisplayArtist).To(Equal("An artist")) - Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(&num)) +var _ = Describe("Lyrics.EffectiveKind", func() { + It("defaults a blank kind to main", func() { + Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) + Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain)) }) - It("Should ignore bad offset", func() { - lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Offset).To(BeNil()) - }) - - It("should accept lines with no text and weird times", func() { - a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60) - lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Hi there"}, - {Start: &b, Value: ""}, - {Start: &c, Value: "Test"}, - {Start: &d, Value: "late"}, - })) - }) - - It("Should support multiple timestamps per line", func() { - a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51) - lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Repeated"}, - {Start: &c, Value: ""}, - {Start: &d, Value: ""}, - })) - }) - - It("Should support parsing multiline string", func() { - a, b := int64(0), int64(10*60*1000+1) - lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is\na multiline\n\n[:0] string"}, - {Start: &b, Value: "This is\nalso one"}, - })) - }) - - It("Does not match timestamp in middle of line", func() { - lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeFalse()) - Expect(lyrics.Line).To(Equal([]Line{ - {Value: "This could [00:00:00] be a synced file"}, - })) - }) - - It("Allows timestamp in middle of line if also at beginning", func() { - a, b := int64(0), int64(1000) - lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is [00:00:00] be a synced file"}, - {Start: &b, Value: "Line 2"}, - })) - }) - - It("Ignores lines in synchronized lyric prior to first timestamp", func() { - a := int64(0) - lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Text"}, - })) - }) - - It("Handles all possible ms cases", func() { - a, b, c := int64(1), int64(10), int64(100) - lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "a"}, - {Start: &b, Value: "b"}, - {Start: &c, Value: "c"}, - })) - }) - - It("Properly sorts repeated lyrics out of order", func() { - a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51) - lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Test"}, - {Start: &c, Value: "Not repeated"}, - {Start: &d, Value: "Repeated"}, - {Start: &e, Value: "Test"}, - })) + It("returns the kind as-is when set", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation)) + }) +}) + +var _ = Describe("Lyrics.IsMainKind", func() { + It("is true for a blank (untyped) kind", func() { + Expect(Lyrics{}.IsMainKind()).To(BeTrue()) + }) + + It("is true for the main kind", func() { + Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue()) + }) + + It("is false for translation and pronunciation kinds", func() { + Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse()) + Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse()) + }) +}) + +var _ = Describe("LyricList.Main", func() { + It("returns false when the list is empty", func() { + _, ok := LyricList{}.Main() + Expect(ok).To(BeFalse()) + }) + + It("returns the main-kind entry when present", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindMain, Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Kind).To(Equal(LyricKindMain)) + }) + + It("falls back to the first entry when no main kind exists", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Kind: LyricKindPronunciation, Lang: "ja"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("en")) + }) + + It("treats a blank kind as main", func() { + list := LyricList{ + {Kind: LyricKindTranslation, Lang: "en"}, + {Lang: "xxx"}, + } + main, ok := list.Main() + Expect(ok).To(BeTrue()) + Expect(main.Lang).To(Equal("xxx")) }) }) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go new file mode 100644 index 000000000..43d4699af --- /dev/null +++ b/model/lyrics_ttml.go @@ -0,0 +1,1296 @@ +package model + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + defaultTTMLFrameRate = 30.0 + defaultTTMLSubFrameRate = 1.0 + defaultTTMLTickRate = 1.0 + + ttmlBackgroundAgentPrefix = "__nd_bg__|" +) + +var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`) +var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`) + +type ttmlTimeKind int + +const ( + ttmlTimeAbsolute ttmlTimeKind = iota + ttmlTimeOffset + ttmlTimeAmbiguous +) + +type ttmlTimingParams struct { + frameRate float64 + subFrameRate float64 + tickRate float64 +} + +type ttmlTimingContext struct { + lang string + role string + agentID string + begin int64 + hasBegin bool + end int64 + hasEnd bool + invalid bool +} + +type ttmlLineRef struct { + order int + line Line +} + +type ttmlMetadataEntry struct { + key string + line Line + seq int +} + +type ttmlResolvedMetadataLine struct { + order int + seq int + line Line +} + +type ttmlDefinedAgent struct { + ID string + Type string + Name string +} + +type ttmlPiece struct { + raw string + cue *Cue + isBreak bool +} + +type ttmlParser struct { + decoder *xml.Decoder + params ttmlTimingParams + + mainLangOrder []string + mainLinesByLang map[string][]Line + + mainLineRefsByKey map[string]ttmlLineRef + mainLineOrder int + + translationLangOrder []string + translationEntriesByLg map[string][]ttmlMetadataEntry + + pronunciationLangOrder []string + pronunciationEntriesByLg map[string][]ttmlMetadataEntry + + definedAgents map[string]ttmlDefinedAgent + + metadataSeq int +} + +func isTTMLDocument(contents []byte) bool { + decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents))) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok { + return strings.EqualFold(start.Name.Local, "tt") + } + } +} + +func parseTTML(defaultLang string, contents []byte) (LyricList, error) { + contents = xmlEncodingRegex.ReplaceAll(contents, []byte(``)) + + // Skip non-TTML content so sniffing doesn't run the full TTML parse on plain + // text — isTTMLDocument does a cheap decode that stops at the first element. + // Checked after the encoding fixup so UTF-16-declared documents are recognized. + if !isTTMLDocument(contents) { + return nil, nil + } + + p := ttmlParser{ + decoder: xml.NewDecoder(bytes.NewReader(contents)), + params: ttmlTimingParams{ + frameRate: defaultTTMLFrameRate, + subFrameRate: defaultTTMLSubFrameRate, + tickRate: defaultTTMLTickRate, + }, + mainLinesByLang: make(map[string][]Line), + mainLineRefsByKey: make(map[string]ttmlLineRef), + translationEntriesByLg: make(map[string][]ttmlMetadataEntry), + pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry), + definedAgents: make(map[string]ttmlDefinedAgent), + } + + root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)} + + for { + token, err := p.decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + start, ok := token.(xml.StartElement) + if !ok { + continue + } + + if err := p.parseElement(start, root); err != nil { + return nil, err + } + } + + return p.toLyricList(), nil +} + +func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error { + local := strings.ToLower(start.Name.Local) + if local == "tt" { + p.updateTimingParams(start.Attr) + } + + switch local { + case "translation": + return p.parseMetadataTrack(start, parent, LyricKindTranslation) + case "transliteration": + return p.parseMetadataTrack(start, parent, LyricKindPronunciation) + case "agent": + return p.parseAgentDefinition(start) + } + + ctx := p.childContext(start.Attr, parent) + if local == "p" { + lineText, tokens, err := p.parseParagraph(ctx) + if err != nil { + return err + } + if ctx.invalid || lineText == "" { + return nil + } + + parsedLine := Line{Value: lineText} + if ctx.hasBegin { + startMs := ctx.begin + parsedLine.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedLine.End = &endMs + } + if len(tokens) > 0 { + parsedLine.Cue = tokens + } + parsedLine = normalizeLineTiming(parsedLine) + + lineKey, _ := attrValue(start.Attr, "key") + p.addMainLine(ctx.lang, lineKey, parsedLine) + return nil + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + nextParent := ctx + if ctx.invalid { + // Best effort: ignore invalid timing in container elements, and + // continue traversing descendants with parent context. + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error { + ctx := p.childContext(start.Attr, parent) + lang := normalizeLyricLang(ctx.lang) + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "text") { + entry, ok, err := p.parseMetadataText(t, ctx) + if err != nil { + return err + } + if ok { + p.addMetadataEntry(kind, lang, entry) + } + continue + } + + nextParent := ctx + if ctx.invalid { + nextParent = parent + } + if err := p.parseElement(t, nextParent); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return nil + } + } + } +} + +func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error { + id, ok := attrValue(start.Attr, "id") + id = strings.TrimSpace(id) + if !ok || id == "" { + return p.skipElement(start) + } + + agent := ttmlDefinedAgent{ + ID: id, + Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))), + } + + for { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch t := token.(type) { + case xml.StartElement: + if strings.EqualFold(t.Name.Local, "name") { + name, err := p.collectElementText(t) + if err != nil { + return err + } + name = sanitizeTTMLText(name) + if name != "" && agent.Name == "" { + agent.Name = name + } + continue + } + if err := p.skipElement(t); err != nil { + return err + } + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + p.definedAgents[agent.ID] = agent + return nil + } + } + } +} + +func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) { + forKey, hasFor := attrValue(start.Attr, "for") + forKey = strings.TrimSpace(forKey) + + pieces, err := p.parseInlineElement(start, parent) + if err != nil { + return ttmlMetadataEntry{}, false, err + } + if !hasFor || forKey == "" { + return ttmlMetadataEntry{}, false, nil + } + + ctx := p.childContext(start.Attr, parent) + if ctx.invalid { + return ttmlMetadataEntry{}, false, nil + } + + value, tokens := buildTTMLLineFromPieces(pieces) + line := Line{Value: value} + if ctx.hasBegin { + startMs := ctx.begin + line.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + line.End = &endMs + } + if len(tokens) > 0 { + line.Cue = tokens + } + line = normalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + return ttmlMetadataEntry{}, false, nil + } + + return ttmlMetadataEntry{key: forKey, line: line}, true, nil +} + +func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) { + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return "", nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, parent) + if err != nil { + return "", nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, "p") { + value, tokens := buildTTMLLineFromPieces(pieces) + return value, tokens, nil + } + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) { + local := strings.ToLower(start.Name.Local) + if local == "br" { + return []ttmlPiece{{isBreak: true}}, nil + } + + ctx := p.childContext(start.Attr, parent) + _, hasBegin := attrValue(start.Attr, "begin") + _, hasEnd := attrValue(start.Attr, "end") + _, hasDur := attrValue(start.Attr, "dur") + hasOwnTiming := hasBegin || hasEnd || hasDur + + var pieces []ttmlPiece + + for { + token, err := p.decoder.Token() + if err != nil { + return nil, err + } + + switch t := token.(type) { + case xml.StartElement: + inlinePieces, err := p.parseInlineElement(t, ctx) + if err != nil { + return nil, err + } + pieces = append(pieces, inlinePieces...) + case xml.EndElement: + if !strings.EqualFold(t.Name.Local, start.Name.Local) { + continue + } + + if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) { + rawValue := concatTTMLPieceRaw(pieces) + tokenText := sanitizeTTMLText(rawValue) + if tokenText != "" { + parsedToken := Cue{ + AgentID: p.resolveCueAgentID(ctx), + } + if ctx.hasBegin { + startMs := ctx.begin + parsedToken.Start = &startMs + } + if ctx.hasEnd { + endMs := ctx.end + parsedToken.End = &endMs + } + + return []ttmlPiece{{ + raw: rawValue, + cue: &parsedToken, + }}, nil + } + } + + return pieces, nil + case xml.CharData: + pieces = append(pieces, ttmlPiece{raw: string(t)}) + } + } +} + +func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { + finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces)) + for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { + finalized = finalized[1:] + } + for len(finalized) > 0 { + last := finalized[len(finalized)-1] + if last.text != "" || len(last.cues) > 0 { + break + } + finalized = finalized[:len(finalized)-1] + } + + var value strings.Builder + cues := make([]Cue, 0, 8) + byteOffset := 0 + for i, line := range finalized { + if i > 0 { + value.WriteByte('\n') + byteOffset++ + } + value.WriteString(line.text) + for _, cue := range line.cues { + cue.ByteStart += byteOffset + cue.ByteEnd += byteOffset + cues = append(cues, cue) + } + byteOffset += len(line.text) + } + + return value.String(), cues +} + +type ttmlFinalLine struct { + text string + cues []Cue +} + +func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine { + finalized := make([]ttmlFinalLine, 0, len(lines)) + for _, line := range lines { + text, cues := finalizeTTMLLogicalLine(line) + finalized = append(finalized, ttmlFinalLine{text: text, cues: cues}) + } + return finalized +} + +func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece { + lines := [][]ttmlPiece{{}} + prevEndedWithSpace := true // leading whitespace on a fresh line is dropped + for _, piece := range pieces { + if piece.isBreak { + lines = append(lines, []ttmlPiece{}) + prevEndedWithSpace = true + continue + } + + raw := normalizeTTMLPieceRaw(piece.raw) + // Collapse whitespace across piece boundaries: a piece's leading space is + // redundant when the text emitted so far already ends with one. + if prevEndedWithSpace { + raw = strings.TrimPrefix(raw, " ") + } + if raw == "" { + continue + } + lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ + raw: raw, + cue: gg.Clone(piece.cue), + }) + prevEndedWithSpace = strings.HasSuffix(raw, " ") + } + return lines +} + +func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) { + rawLine := concatTTMLPieceRaw(line) + if rawLine == "" { + return "", nil + } + + leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace)) + rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace)) + trimmedEnd := len(rawLine) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + trimmed := strings.TrimSpace(rawLine) + cues := make([]Cue, 0, len(line)) + cursor := 0 + for _, piece := range line { + pieceEnd := cursor + len(piece.raw) + if piece.cue != nil { + byteStart := max(cursor, leftTrimBytes) + byteEnd := min(pieceEnd, trimmedEnd) + if byteStart < byteEnd { + cue := *piece.cue + cue.Value = rawLine[byteStart:byteEnd] + cue.ByteStart = byteStart - leftTrimBytes + cue.ByteEnd = byteEnd - leftTrimBytes - 1 + cues = append(cues, cue) + } + } + cursor = pieceEnd + } + + return trimmed, cues +} + +// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode +// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that +// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so +// linefeeds and other whitespace runs collapse to a single space. Collapsing is +// applied unconditionally; xml:space="preserve" is not supported (no lyric +// source in practice relies on it). Hard line breaks come only from
+// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed +// indentation between elements does not inject spurious newlines. +func normalizeTTMLPieceRaw(raw string) string { + raw = str.SanitizeText(raw) + return collapseTTMLWhitespace(raw) +} + +func collapseTTMLWhitespace(raw string) string { + var b strings.Builder + b.Grow(len(raw)) + prevSpace := false + for _, r := range raw { + // Only the XML S production (space, tab, CR, LF) is collapsible whitespace. + // Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not + // whitespace, so they pass through unchanged. + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + if !prevSpace { + b.WriteByte(' ') + prevSpace = true + } + continue + } + b.WriteRune(r) + prevSpace = false + } + return b.String() +} + +func concatTTMLPieceRaw(pieces []ttmlPiece) string { + var raw strings.Builder + for _, piece := range pieces { + raw.WriteString(normalizeTTMLPieceRaw(piece.raw)) + } + return raw.String() +} + +func ttmlPiecesContainCue(pieces []ttmlPiece) bool { + for _, piece := range pieces { + if piece.cue != nil { + return true + } + } + return false +} + +func (p *ttmlParser) toLyricList() LyricList { + res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder)) + for _, lang := range p.mainLangOrder { + lines := p.mainLinesByLang[lang] + if len(lines) == 0 { + continue + } + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: LyricKindMain, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...) + res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...) + return res +} + +func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList { + res := make(LyricList, 0, len(langOrder)) + + for _, lang := range langOrder { + entries := entriesByLang[lang] + if len(entries) == 0 { + continue + } + + seenKeys := make(map[string]struct{}, len(entries)) + resolved := make([]ttmlResolvedMetadataLine, 0, len(entries)) + for _, entry := range entries { + if _, exists := seenKeys[entry.key]; exists { + continue + } + seenKeys[entry.key] = struct{}{} + + ref, ok := p.mainLineRefsByKey[entry.key] + if !ok { + log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key) + continue + } + + line := entry.line + if line.Start == nil && ref.line.Start != nil { + startMs := *ref.line.Start + line.Start = &startMs + } + if line.End == nil && ref.line.End != nil { + endMs := *ref.line.End + line.End = &endMs + } + line = normalizeLineTiming(line) + + if line.Value == "" && len(line.Cue) == 0 { + continue + } + + resolved = append(resolved, ttmlResolvedMetadataLine{ + order: ref.order, + seq: entry.seq, + line: line, + }) + } + + if len(resolved) == 0 { + continue + } + + sort.SliceStable(resolved, func(i, j int) bool { + if resolved[i].order != resolved[j].order { + return resolved[i].order < resolved[j].order + } + return resolved[i].seq < resolved[j].seq + }) + + lines := make([]Line, len(resolved)) + for i := range resolved { + lines[i] = resolved[i].line + } + + res = append(res, p.finalizeLyrics(Lyrics{ + Kind: kind, + Lang: lang, + Line: lines, + Synced: linesAreSynced(lines), + })) + } + + return res +} + +func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) + return normalizeLyrics(lyrics) +} + +func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { + if len(lines) == 0 { + return lines, nil + } + + usedOrder := make([]string, 0, 4) + usedSet := make(map[string]struct{}, 4) + sawEmptyCue := false + + for i := range lines { + for j := range lines[i].Cue { + agentID := strings.TrimSpace(lines[i].Cue[j].AgentID) + if agentID == "" { + sawEmptyCue = true + continue + } + if _, exists := usedSet[agentID]; !exists { + usedSet[agentID] = struct{}{} + usedOrder = append(usedOrder, agentID) + } + } + } + + if len(usedOrder) == 0 { + return lines, nil + } + + mainID := "" + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if role != "bg" && role != "group" { + mainID = agentID + break + } + } + if mainID == "" && sawEmptyCue { + mainID = "main" + } + if mainID == "" { + for _, agentID := range usedOrder { + if p.baseRoleForAgent(agentID) != "bg" { + mainID = agentID + break + } + } + } + if mainID == "" { + mainID = usedOrder[0] + } + + if _, exists := usedSet[mainID]; !exists { + usedSet[mainID] = struct{}{} + usedOrder = append([]string{mainID}, usedOrder...) + } + + for i := range lines { + for j := range lines[i].Cue { + if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" { + lines[i].Cue[j].AgentID = mainID + } + } + } + + agents := make([]Agent, 0, len(usedOrder)) + for _, agentID := range usedOrder { + role := p.baseRoleForAgent(agentID) + if agentID == mainID { + role = "main" + } + agent := Agent{ + ID: agentID, + Role: role, + Name: p.agentNameForID(agentID), + } + agents = append(agents, agent) + } + + return lines, agents +} + +func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string { + agentID := strings.TrimSpace(ctx.agentID) + if contextHasRole(ctx.role, "x-bg") { + if agentID == "" { + agentID = "main" + } + return backgroundAgentID(agentID) + } + return agentID +} + +func (p *ttmlParser) baseRoleForAgent(agentID string) string { + if isBackgroundAgentID(agentID) { + return "bg" + } + + if agent, ok := p.definedAgents[agentID]; ok { + switch agent.Type { + case "group": + return "group" + default: + return "voice" + } + } + + return "voice" +} + +func (p *ttmlParser) agentNameForID(agentID string) string { + if isBackgroundAgentID(agentID) { + baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix) + if baseID == "main" { + return "" + } + if agent, ok := p.definedAgents[baseID]; ok { + return agent.Name + } + return "" + } + + if agent, ok := p.definedAgents[agentID]; ok { + return agent.Name + } + + return "" +} + +func backgroundAgentID(agentID string) string { + return ttmlBackgroundAgentPrefix + agentID +} + +func isBackgroundAgentID(agentID string) bool { + return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix) +} + +func contextHasRole(roles string, role string) bool { + lowerRole := strings.ToLower(role) + for _, candidate := range strings.Fields(strings.ToLower(roles)) { + if candidate == lowerRole { + return true + } + } + return false +} + +func appendTTMLRoles(existing string, roles string) string { + for _, role := range strings.Fields(roles) { + if contextHasRole(existing, role) { + continue + } + if existing == "" { + existing = role + } else { + existing += " " + role + } + } + return existing +} + +func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) { + lang = normalizeLyricLang(lang) + if _, ok := p.mainLinesByLang[lang]; !ok { + p.mainLangOrder = append(p.mainLangOrder, lang) + } + p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line) + + lineKey = strings.TrimSpace(lineKey) + if lineKey != "" { + if _, exists := p.mainLineRefsByKey[lineKey]; !exists { + p.mainLineRefsByKey[lineKey] = ttmlLineRef{ + order: p.mainLineOrder, + line: line, + } + } + } + p.mainLineOrder++ +} + +func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) { + lang = normalizeLyricLang(lang) + entry.seq = p.metadataSeq + p.metadataSeq++ + + switch kind { + case LyricKindTranslation: + if _, ok := p.translationEntriesByLg[lang]; !ok { + p.translationLangOrder = append(p.translationLangOrder, lang) + } + p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry) + case LyricKindPronunciation: + if _, ok := p.pronunciationEntriesByLg[lang]; !ok { + p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang) + } + p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry) + } +} + +func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext { + ctx := parent + + if lang, ok := attrValue(attrs, "lang"); ok { + ctx.lang = normalizeLyricLang(lang) + } + if agentID, ok := attrValue(attrs, "agent"); ok { + ctx.agentID = strings.TrimSpace(agentID) + } + if role, ok := attrValue(attrs, "role"); ok { + role = strings.TrimSpace(role) + if role != "" { + ctx.role = appendTTMLRoles(ctx.role, role) + } + } + + beginExpr, hasBegin := attrValue(attrs, "begin") + endExpr, hasEnd := attrValue(attrs, "end") + durExpr, hasDur := attrValue(attrs, "dur") + + if hasBegin { + begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := int64(0) + if parent.hasBegin { + base = parent.begin + } + ctx.begin = resolveTTMLTime(begin, kind, base, parent) + ctx.hasBegin = true + } else { + ctx.begin = parent.begin + ctx.hasBegin = parent.hasBegin + } + + var calculatedEnd int64 + calculatedHasEnd := false + + if hasEnd { + end, kind, ok := parseTTMLTimeExpression(endExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + + base := ctx.begin + if !ctx.hasBegin { + base = parent.begin + } + calculatedEnd = resolveTTMLTime(end, kind, base, parent) + calculatedHasEnd = true + } + + if hasDur { + dur, ok := parseTTMLDurationExpression(durExpr, p.params) + if !ok { + ctx.invalid = true + return ctx + } + if ctx.hasBegin { + durEnd := ctx.begin + dur + if !calculatedHasEnd || durEnd < calculatedEnd { + calculatedEnd = durEnd + calculatedHasEnd = true + } + } + } + + if !calculatedHasEnd && parent.hasEnd { + calculatedEnd = parent.end + calculatedHasEnd = true + } + + ctx.end = calculatedEnd + ctx.hasEnd = calculatedHasEnd + return ctx +} + +func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) { + frameRate := p.params.frameRate + if value, ok := attrValue(attrs, "frameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + frameRate = parsed + } + } + + if value, ok := attrValue(attrs, "frameRateMultiplier"); ok { + parts := strings.Fields(value) + if len(parts) == 2 { + numerator, errA := strconv.ParseFloat(parts[0], 64) + denominator, errB := strconv.ParseFloat(parts[1], 64) + if errA == nil && errB == nil && denominator > 0 { + frameRate = frameRate * (numerator / denominator) + } + } + } + + subFrameRate := p.params.subFrameRate + if value, ok := attrValue(attrs, "subFrameRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + subFrameRate = parsed + } + } + + tickRate := p.params.tickRate + if value, ok := attrValue(attrs, "tickRate"); ok { + if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { + tickRate = parsed + } + } + + p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate) + p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate) + p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate) +} + +func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) { + value, _, ok := parseTTMLTimeExpression(expr, params) + return value, ok +} + +func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 { + switch kind { + case ttmlTimeAbsolute: + return value + case ttmlTimeOffset: + return base + value + case ttmlTimeAmbiguous: + absolute := value + offset := base + value + + // No parent timing context → no reference frame for offsets. + // Prefer absolute when offset differs (i.e., base > 0). + if !parent.hasBegin && !parent.hasEnd && base != 0 { + return absolute + } + + if parent.hasBegin && parent.hasEnd { + absoluteInParent := absolute >= parent.begin && absolute <= parent.end + offsetInParent := offset >= parent.begin && offset <= parent.end + if absoluteInParent && !offsetInParent { + return absolute + } + if offsetInParent && !absoluteInParent { + return offset + } + } + + if parent.hasBegin { + if absolute < parent.begin && offset >= parent.begin { + return offset + } + if absolute >= parent.begin && offset > absolute { + return absolute + } + } + return offset + default: + return base + value + } +} + +func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) { + expr = strings.TrimSpace(expr) + if expr == "" { + return 0, ttmlTimeOffset, false + } + + lower := strings.ToLower(expr) + if strings.Contains(lower, "wallclock(") || + strings.Contains(lower, ".begin") || + strings.Contains(lower, ".end") { + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } + + // Best-effort support for non-standard TTML seen in the wild where a + // bare decimal value is used (implicitly seconds), e.g. "0.170". + if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 { + return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true + } + + if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 { + value, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return 0, ttmlTimeOffset, false + } + + unit := matches[2] + seconds := 0.0 + switch unit { + case "h": + seconds = value * 60 * 60 + case "m": + seconds = value * 60 + case "s": + seconds = value + case "ms": + seconds = value / 1000 + case "f": + seconds = value / params.frameRate + case "t": + seconds = value / params.tickRate + default: + return 0, ttmlTimeOffset, false + } + + return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true + } + + colonCount := strings.Count(expr, ":") + switch colonCount { + case 1, 2: + clockMs, ok := parseTTMLClockTime(expr) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return clockMs, ttmlTimeAbsolute, true + case 3: + framesMs, ok := parseTTMLFrameTime(expr, params) + if !ok { + return 0, ttmlTimeAbsolute, false + } + return framesMs, ttmlTimeAbsolute, true + default: + log.Warn("Unsupported TTML time expression", "value", expr) + return 0, ttmlTimeOffset, false + } +} + +func parseTTMLClockTime(value string) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 2 && len(parts) != 3 { + return 0, false + } + + hours := int64(0) + minutesIdx := 0 + if len(parts) == 3 { + h, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + hours = h + minutesIdx = 1 + } + + minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64) + if err != nil { + return 0, false + } + + totalSeconds := float64(hours*60*60+minutes*60) + seconds + return int64(math.Round(totalSeconds * 1000)), true +} + +func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) { + parts := strings.Split(value, ":") + if len(parts) != 4 { + return 0, false + } + + hours, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + + minutes, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, false + } + + seconds, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return 0, false + } + + frameParts := strings.SplitN(parts[3], ".", 2) + frames, err := strconv.ParseFloat(frameParts[0], 64) + if err != nil { + return 0, false + } + + subFrames := 0.0 + if len(frameParts) == 2 { + subFrames, err = strconv.ParseFloat(frameParts[1], 64) + if err != nil { + return 0, false + } + } + + totalSeconds := float64(hours*60*60 + minutes*60 + seconds) + totalSeconds += frames / params.frameRate + totalSeconds += subFrames / (params.subFrameRate * params.frameRate) + + return int64(math.Round(totalSeconds * 1000)), true +} + +func attrValue(attrs []xml.Attr, key string) (string, bool) { + for _, attr := range attrs { + if strings.EqualFold(attr.Name.Local, key) { + return strings.TrimSpace(attr.Value), true + } + } + return "", false +} + +func attrOrEmpty(attrs []xml.Attr, key string) string { + value, _ := attrValue(attrs, key) + return value +} + +func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) { + var text strings.Builder + + for { + token, err := p.decoder.Token() + if err != nil { + return "", err + } + + switch t := token.(type) { + case xml.StartElement: + value, err := p.collectElementText(t) + if err != nil { + return "", err + } + text.WriteString(value) + case xml.EndElement: + if strings.EqualFold(t.Name.Local, start.Name.Local) { + return text.String(), nil + } + case xml.CharData: + text.WriteString(string(t)) + } + } +} + +func (p *ttmlParser) skipElement(_ xml.StartElement) error { + depth := 1 + for depth > 0 { + token, err := p.decoder.Token() + if err != nil { + return err + } + + switch token.(type) { + case xml.StartElement: + depth++ + case xml.EndElement: + depth-- + } + } + return nil +} + +func normalizeLyricLang(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + if lang == "" { + return "xxx" + } + return lang +} + +func sanitizeTTMLText(raw string) string { + raw = str.SanitizeText(raw) + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + lines := strings.Split(raw, "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func linesAreSynced(lines []Line) bool { + for i := range lines { + if lines[i].Start != nil { + return true + } + for j := range lines[i].Cue { + if lines[i].Cue[j].Start != nil { + return true + } + } + } + return false +} diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go new file mode 100644 index 000000000..bbdf5c7c4 --- /dev/null +++ b/model/lyrics_ttml_test.go @@ -0,0 +1,551 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseTTML", func() { + Describe("Multi-language and timing", func() { + It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { + content := []byte(` + + +
+

Line one

+

Line two
with break

+
+
+

Linha

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(2)) + + By("parsing the English track") + eng := list[0] + Expect(eng.Lang).To(Equal("eng")) + Expect(eng.Synced).To(BeTrue()) + Expect(eng.Line[0].Start).To(Equal(new(int64(3000)))) + Expect(eng.Line[0].Value).To(Equal("Line one")) + Expect(eng.Line[1].Start).To(Equal(new(int64(4517)))) + Expect(eng.Line[1].Value).To(Equal("Line two\nwith break")) + + By("parsing the Portuguese track") + por := list[1] + Expect(por.Lang).To(Equal("por")) + Expect(por.Line[0].Start).To(Equal(new(int64(4500)))) + Expect(por.Line[0].Value).To(Equal("Linha")) + }) + }) + + Describe("Unsupported cue handling", func() { + It("should skip wallclock cues and keep valid ones", func() { + content := []byte(` + + +
+

Skip me

+

Keep me

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(1000)))) + Expect(list[0].Line[0].Value).To(Equal("Keep me")) + }) + }) + + Describe("Begin/End/Dur with inheritance", func() { + It("should correctly accumulate nested timing from body, div, and p elements", func() { + content := []byte(` + + +
+

First line

+

Second line

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("eng")) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(16000)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(18000)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Non-standard bare second offsets", func() { + It("should parse bare decimal numbers as seconds", func() { + content := []byte(` + + +
+

First line

+

Second line

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Start).To(Equal(new(int64(10170)))) + Expect(list[0].Line[0].Value).To(Equal("First line")) + Expect(list[0].Line[1].Start).To(Equal(new(int64(13710)))) + Expect(list[0].Line[1].Value).To(Equal("Second line")) + }) + }) + + Describe("Word timing tokens", func() { + It("should extract timed tokens from spans including background role", func() { + content := []byte(` + + +
+

+ Hello + echo +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(1000)))) + Expect(line.Value).To(Equal("Hello echo")) + Expect(line.End).To(Equal(new(int64(3000)))) + Expect(line.Cue).To(HaveLen(3)) + + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"})) + }) + + It("should append role tokens exactly instead of using substring matches", func() { + content := []byte(` + + +
+

LeadEcho

+
+ +
`) + + list, err := parseTTML("xxx", content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main")) + }) + + It("should parse named TTML agents into main, voice, and group roles", func() { + content := []byte(` + + + + Chris Martin + Jin + All + + + +
+

You

+

and

+

All

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "v1", Role: "main", Name: "Chris Martin"}, + {ID: "v2", Role: "voice", Name: "Jin"}, + {ID: "v1000", Role: "group", Name: "All"}, + })) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1")) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2")) + Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000")) + }) + + It("should avoid collisions between derived background agents and explicit TTML agent ids", func() { + content := []byte(` + + + + Lead + Existing Background Id + + + +
+

+ Lead + Echo +

+

+ Named +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "lead", Role: "main", Name: "Lead"}, + {ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"}, + {ID: "lead__bg", Role: "voice", Name: "Existing Background Id"}, + })) + Expect(list[0].Line).To(HaveLen(2)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead")) + Expect(list[0].Line[1].Cue).To(HaveLen(1)) + Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg")) + }) + + It("should fill missing cue agent ids with the resolved main agent", func() { + content := []byte(` + + + + Guest Vocal + + + +
+

+ Lead + Guest +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Agents).To(Equal([]Agent{ + {ID: "guest", Role: "main", Name: "Guest Vocal"}, + })) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Cue).To(HaveLen(2)) + Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest")) + Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest")) + }) + }) + + Describe("Whitespace handling", func() { + It("should collapse pretty-print indentation between spans into single spaces, not line breaks", func() { + content := []byte(` + + +
+

+ It + in, + + (When you + slide) + +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Value).To(Equal("It in, (When you slide)")) + Expect(line.Value).ToNot(ContainSubstring("\n")) + Expect(line.Cue).To(HaveLen(4)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(82889)), End: new(int64(83127)), Value: "It", ByteStart: 0, ByteEnd: 1, AgentID: "v2"})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(83374)), End: new(int64(83938)), Value: "in,", ByteStart: 3, ByteEnd: 5, AgentID: "v2"})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(83881)), End: new(int64(84243)), Value: "(When you", ByteStart: 7, ByteEnd: 15, AgentID: "__nd_bg__|v2"})) + Expect(line.Cue[3]).To(Equal(Cue{Start: new(int64(86232)), End: new(int64(86859)), Value: "slide)", ByteStart: 17, ByteEnd: 22, AgentID: "__nd_bg__|v2"})) + }) + + It("should preserve explicit
as a line break", func() { + content := []byte(` + + +
+

+ first +
+ second +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("first\nsecond")) + }) + + It("should only collapse XML whitespace, leaving other Unicode spaces intact", func() { + // Whitespace collapsing only touches the XML S characters + // (space/tab/CR/LF). Other Unicode spaces like U+3000 are left as-is: + // the U+3000 inside a span survives, while the pretty-print newline + // between spans still collapses to a single space. + content := []byte("\n" + + "\n" + + " \n" + + "
\n" + + "

\n" + + " あ い\n" + + " \n" + + "

\n" + + "
\n" + + " \n" + + "
") + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("あ い う")) + Expect(list[0].Line[0].Cue[0].Value).To(Equal("あ い")) + }) + }) + + Describe("Interleaved background cue timing", func() { + It("should not corrupt a main cue's end time when a background cue is earlier in time", func() { + // Background spans (x-bg) appear after the main spans in document order + // but their timings interleave with the main timeline. End-time + // normalization must be per agent so the last main cue keeps its real + // end instead of collapsing to its own start. + content := []byte(` + + +
+

real slow (When you slide)

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Cue).To(HaveLen(4)) + + cuesByAgent := map[string][]Cue{} + for _, c := range line.Cue { + cuesByAgent[c.AgentID] = append(cuesByAgent[c.AgentID], c) + } + + mainCues := cuesByAgent["v2"] + Expect(mainCues).To(HaveLen(2)) + Expect(*mainCues[0].End).To(Equal(int64(85934))) // "real" + // "slow" must keep its real end (86751), not collapse to its start. + Expect(*mainCues[1].Start).To(Equal(int64(85934))) + Expect(*mainCues[1].End).To(Equal(int64(86751))) + + bgCues := cuesByAgent["__nd_bg__|v2"] + Expect(bgCues).To(HaveLen(2)) + Expect(*bgCues[0].End).To(Equal(int64(84243))) // "(When you" + Expect(*bgCues[1].End).To(Equal(int64(86859))) // "slide)" + }) + }) + + Describe("Ambiguous decimal timing", func() { + It("should prefer absolute timing when values fall inside parent window", func() { + content := []byte(` + + +
+

+ go + go +

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(HaveLen(1)) + + line := list[0].Line[0] + Expect(line.Start).To(Equal(new(int64(43444)))) + Expect(line.Value).To(Equal("go go")) + Expect(line.End).To(Equal(new(int64(45570)))) + Expect(line.Cue).To(HaveLen(2)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4})) + }) + }) + + Describe("Unsynced fallback", func() { + It("should return unsynced lyrics when no timing is present", func() { + content := []byte(` + + +
+

No timing here

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeFalse()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Start).To(BeNil()) + Expect(list[0].Line[0].Value).To(Equal("No timing here")) + }) + }) + + Describe("Metadata tracks", func() { + It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() { + content := []byte(` + + + + + + + Hola + Skip me + + + + + konni + + + + + + +
+

こんにちは

+

こんばんは

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(3)) + + By("checking the main track") + main := list[0] + Expect(main.Kind).To(Equal("main")) + Expect(main.Lang).To(Equal("ja")) + Expect(main.Line).To(HaveLen(2)) + + By("checking the translation track") + translation := list[1] + Expect(translation.Kind).To(Equal("translation")) + Expect(translation.Lang).To(Equal("es")) + Expect(translation.Line).To(HaveLen(1)) + Expect(translation.Line[0].Start).To(Equal(new(int64(1000)))) + Expect(translation.Line[0].Value).To(Equal("Hola")) + Expect(translation.Line[0].End).To(Equal(new(int64(1500)))) + + By("checking the pronunciation track") + pronunciation := list[2] + Expect(pronunciation.Kind).To(Equal("pronunciation")) + Expect(pronunciation.Lang).To(Equal("ja-latn")) + Expect(pronunciation.Line).To(HaveLen(1)) + Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000)))) + Expect(pronunciation.Line[0].Value).To(Equal("konni")) + Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600)))) + Expect(pronunciation.Line[0].Cue).To(HaveLen(2)) + Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1})) + Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4})) + }) + }) + + Describe("Pronunciation with bare decimal end times", func() { + It("should correctly parse bare decimal times in transliteration spans", func() { + content := []byte(` + + + + + + + I woke up + + + + + + +
+

起きた

+
+ +
`) + + list, err := parseTTML("xxx", content) + Expect(err).ToNot(HaveOccurred()) + + var pronunciation *Lyrics + for i := range list { + if list[i].Kind == "pronunciation" { + pronunciation = &list[i] + break + } + } + Expect(pronunciation).ToNot(BeNil()) + Expect(pronunciation.Line).To(HaveLen(1)) + + line := pronunciation.Line[0] + Expect(line.Start).To(Equal(new(int64(2747)))) + Expect(line.Value).To(Equal("I woke up")) + Expect(line.Cue).To(HaveLen(3)) + Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0})) + Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5})) + Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8})) + }) + }) +}) diff --git a/model/mediafile.go b/model/mediafile.go index 6be8402ae..0fd172cee 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -16,6 +16,8 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" ) @@ -54,7 +56,7 @@ type MediaFile struct { Duration float32 `structs:"duration" json:"duration"` BitRate int `structs:"bit_rate" json:"bitRate"` SampleRate int `structs:"sample_rate" json:"sampleRate"` - BitDepth int `structs:"bit_depth" json:"bitDepth"` + BitDepth *int `structs:"bit_depth" json:"bitDepth,omitempty"` Channels int `structs:"channels" json:"channels"` Codec string `structs:"codec" json:"codec"` ProbeData string `structs:"probe_data" json:"-" hash:"ignore"` @@ -71,7 +73,7 @@ type MediaFile struct { Compilation bool `structs:"compilation" json:"compilation"` Comment string `structs:"comment" json:"comment,omitempty"` Lyrics string `structs:"lyrics" json:"lyrics"` - BPM int `structs:"bpm" json:"bpm,omitempty"` + BPM *int `structs:"bpm" json:"bpm,omitempty"` ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` @@ -137,7 +139,7 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID { } func (mf MediaFile) StructuredLyrics() (LyricList, error) { - lyrics := LyricList{} + var lyrics LyricList err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) if err != nil { return nil, err @@ -150,6 +152,55 @@ func (mf MediaFile) String() string { return mf.Path } +type Work struct { + Name string + MbzWorkID string +} + +type Movement struct { + Name string + Number int32 + Count int32 +} + +func (mf MediaFile) Works() []Work { + names := mf.Tags.Values(TagWork) + if len(names) == 0 { + return nil + } + ids := mf.Tags.Values(TagMusicBrainzWorkID) + works := make([]Work, 0, len(names)) + for i, name := range names { + w := Work{Name: name} + if i < len(ids) { + w.MbzWorkID = ids[i] + } + works = append(works, w) + } + return works +} + +func (mf MediaFile) Movements() []Movement { + names := mf.Tags.Values(TagMovementName) + if len(names) == 0 { + return nil + } + numbers := mf.Tags.Values(TagMovementNumber) + counts := mf.Tags.Values(TagMovementTotal) + movements := make([]Movement, 0, len(names)) + for i, name := range names { + m := Movement{Name: name} + if i < len(numbers) { + m.Number = number.ParseInt[int32](numbers[i]) + } + if i < len(counts) { + m.Count = number.ParseInt[int32](counts[i]) + } + movements = append(movements, m) + } + return movements +} + // Hash returns a hash of the MediaFile based on its tags and audio properties func (mf MediaFile) Hash() string { opts := &hashstructure.HashOptions{ @@ -225,7 +276,7 @@ func (mf MediaFile) inferCodecFromSuffix() string { return "dsd" case "m4a": // AAC if BitDepth==0, ALAC if BitDepth>0 - if mf.BitDepth > 0 { + if gg.V(mf.BitDepth) > 0 { return "alac" } return "aac" @@ -438,6 +489,9 @@ type MediaFileRepository interface { Get(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) + // GetRandom returns up to options.Max media files in random order, applying the same + // filters as GetAll. Sort/Order are ignored. + GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 3547ec4ef..f070f4649 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -564,7 +564,7 @@ var _ = Describe("MediaFile", func() { DescribeTable("infers codec from suffix when Codec field is empty", func(suffix string, bitDepth int, expected string) { - mf := MediaFile{Suffix: suffix, BitDepth: bitDepth} + mf := MediaFile{Suffix: suffix, BitDepth: new(bitDepth)} Expect(mf.AudioCodec()).To(Equal(expected)) }, Entry("mp3", "mp3", 0, "mp3"), @@ -597,13 +597,93 @@ var _ = Describe("MediaFile", func() { ) It("prefers stored codec over suffix inference", func() { - mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0} + mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} Expect(mf.AudioCodec()).To(Equal("alac")) }) }) }) +var _ = Describe("MediaFile.Works", func() { + It("returns nil when there are no work tags", func() { + mf := MediaFile{} + Expect(mf.Works()).To(BeNil()) + }) + + It("pairs a work name with its MbzWorkID", func() { + mf := MediaFile{Tags: Tags{ + TagWork: {"Symphony No. 5"}, + TagMusicBrainzWorkID: {"abc-123"}, + }} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Symphony No. 5", MbzWorkID: "abc-123"}, + })) + }) + + It("leaves MbzWorkID empty when no id is present", func() { + mf := MediaFile{Tags: Tags{TagWork: {"Symphony No. 5"}}} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Symphony No. 5"}, + })) + }) + + It("pairs by index and ignores extra ids", func() { + mf := MediaFile{Tags: Tags{ + TagWork: {"Work A", "Work B"}, + TagMusicBrainzWorkID: {"id-a"}, + }} + Expect(mf.Works()).To(Equal([]Work{ + {Name: "Work A", MbzWorkID: "id-a"}, + {Name: "Work B"}, + })) + }) +}) + +var _ = Describe("MediaFile.Movements", func() { + It("returns nil when there are no movement tags", func() { + mf := MediaFile{} + Expect(mf.Movements()).To(BeNil()) + }) + + It("builds a movement with name, number and count", func() { + mf := MediaFile{Tags: Tags{ + TagMovementName: {"I. Allegro"}, + TagMovementNumber: {"1"}, + TagMovementTotal: {"4"}, + }} + Expect(mf.Movements()).To(Equal([]Movement{ + {Name: "I. Allegro", Number: 1, Count: 4}, + })) + }) + + It("non-numeric number/count yields 0", func() { + mf := MediaFile{Tags: Tags{ + TagMovementName: {"I. Allegro"}, + TagMovementNumber: {"not-a-number"}, + }} + Expect(mf.Movements()).To(Equal([]Movement{ + {Name: "I. Allegro"}, + })) + }) +}) + +var _ = Describe("MediaFile.Hash", func() { + // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, + // or every file would be spuriously re-imported on the next scan. + // Golden hashes were captured at 46221d516 when those fields were plain ints. + It("keeps hashes identical to the pre-pointer-conversion values", func() { + // Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning + // them guarantees the pointer conversion cannot trigger a full-library re-import. + Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622")) + Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689")) + }) + It("changes the hash when a pointer field has a value", func() { + base := MediaFile{Title: "Song"} + Expect(base.Equals(MediaFile{Title: "Song", BPM: new(120)})).To(BeFalse()) + Expect(base.Equals(MediaFile{Title: "Song", BitDepth: new(24)})).To(BeFalse()) + }) +}) + func t(v string) time.Time { var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"} for _, f := range timeFormats { diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 824cad7c2..b3ce4ef02 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -2,6 +2,7 @@ package metadata import ( "cmp" + "context" "encoding/json" "maps" "math" @@ -35,7 +36,11 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.DiscSubtitle = md.String(model.TagDiscSubtitle) mf.CatalogNum = md.String(model.TagCatalogNumber) mf.Comment = md.String(model.TagComment) - mf.BPM = int(math.Round(md.Float(model.TagBPM))) + if f := md.NullableFloat(model.TagBPM); f != nil { + if v := int(math.Round(*f)); v != 0 { + mf.BPM = new(v) + } + } mf.Lyrics = md.mapLyrics() mf.ExplicitStatus = md.mapExplicitStatusTag() @@ -63,7 +68,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.Duration = md.Length() mf.BitRate = md.AudioProperties().BitRate mf.SampleRate = md.AudioProperties().SampleRate - mf.BitDepth = md.AudioProperties().BitDepth + if bd := md.AudioProperties().BitDepth; bd > 0 { + mf.BitDepth = new(bd) + } mf.Channels = md.AudioProperties().Channels mf.Codec = md.AudioProperties().Codec mf.Path = md.FilePath() @@ -133,17 +140,20 @@ func (md Metadata) mapLyrics() string { lyricList := make(model.LyricList, 0, len(rawLyrics)) + ctx := log.NewContext(context.Background(), "file", md.filePath) for _, raw := range rawLyrics { lang := raw.Key() text := raw.Value() - lyrics, err := model.ToLyrics(lang, text) + lyrics, err := model.ParseLyrics(ctx, "", lang, []byte(text)) if err != nil { - log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) + log.Warn(ctx, "Unexpected failure occurred when parsing lyrics", err) continue } - if !lyrics.IsEmpty() { - lyricList = append(lyricList, *lyrics) + for _, lyric := range lyrics { + if !lyric.IsEmpty() { + lyricList = append(lyricList, lyric) + } } } diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index e3adf3fae..75a7ed358 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() { expected := model.LyricList{ {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: P(int64(0))}, - {Value: "English SYLT", Start: P(int64(2500))}, + {Value: "This is", Start: new(int64(0))}, + {Value: "English SYLT", Start: new(int64(2500))}, }, Synced: true}, {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, } @@ -118,4 +117,32 @@ var _ = Describe("ToMediaFile", func() { Expect(actual).To(Equal(expected)) }) }) + + Describe("BPM", func() { + It("maps the BPM tag rounded to the nearest integer", func() { + mf = toMediaFile(model.RawTags{"BPM": {"120.6"}}) + Expect(mf.BPM).To(Equal(new(121))) + }) + It("leaves BPM nil when the tag is absent", func() { + mf = toMediaFile(model.RawTags{}) + Expect(mf.BPM).To(BeNil()) + }) + It("leaves BPM nil when the tag is zero or unparseable", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil()) + Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil()) + }) + }) + + Describe("BitDepth", func() { + It("maps the bit depth when present", func() { + props.AudioProperties = metadata.AudioProperties{BitDepth: 24} + mf = toMediaFile(model.RawTags{}) + Expect(mf.BitDepth).To(Equal(new(24))) + }) + It("leaves BitDepth nil when zero (lossy codecs have no bit depth)", func() { + props.AudioProperties = metadata.AudioProperties{BitDepth: 0} + mf = toMediaFile(model.RawTags{}) + Expect(mf.BitDepth).To(BeNil()) + }) + }) }) diff --git a/model/metadata/map_participants.go b/model/metadata/map_participants.go index e8be6aaab..35f112a92 100644 --- a/model/metadata/map_participants.go +++ b/model/metadata/map_participants.go @@ -94,18 +94,20 @@ func (md Metadata) processPerformers(participants model.Participants, rolesMbzId roleIdx[role] = 0 } + conf := model.TagRolesConf().WithParticipantExceptions(model.TagPerformer) titleCaser := cases.Title(language.Und) for _, performer := range md.Pairs(model.TagPerformer) { - name := performer.Value() subRole := titleCaser.String(performer.Key()) - - artist := model.Artist{ - ID: md.artistID(name), - Name: name, - OrderArtistName: str.SanitizeFieldForSortingNoArticle(name), - MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx), + names := splitParticipantValues(conf, []string{performer.Value()}) + for _, name := range names { + artist := model.Artist{ + ID: md.artistID(name), + Name: name, + OrderArtistName: str.SanitizeFieldForSortingNoArticle(name), + MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx), + } + participants.AddWithSubRole(model.RolePerformer, subRole, artist) } - participants.AddWithSubRole(model.RolePerformer, subRole, artist) } } @@ -171,6 +173,16 @@ func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist { return artists } +// splitParticipantValues splits values by the conf separators, dropping +// duplicated or empty entries. Values are returned unchanged when the conf +// has no separators. +func splitParticipantValues(conf model.TagConf, values []string) []string { + if len(conf.Split) == 0 { + return values + } + return filterDuplicatedOrEmptyValues(conf.SplitTagValue(values)) +} + // getRoleValues returns the values of a role tag, splitting them if necessary func (md Metadata) getRoleValues(role model.TagName) []string { values := md.Strings(role) @@ -181,11 +193,8 @@ func (md Metadata) getRoleValues(role model.TagName) []string { if conf.Split == nil { conf = model.TagRolesConf() } - if len(conf.Split) > 0 { - values = conf.SplitTagValue(values) - return filterDuplicatedOrEmptyValues(values) - } - return values + conf = conf.WithParticipantExceptions(role) + return splitParticipantValues(conf, values) } // getArtistValues returns the values of a single or multi artist tag, splitting them if necessary @@ -202,11 +211,8 @@ func (md Metadata) getArtistValues(single, multi model.TagName) []string { if conf.Split == nil { conf = model.TagArtistsConf() } - if len(conf.Split) > 0 { - vSingle = conf.SplitTagValue(vSingle) - return filterDuplicatedOrEmptyValues(vSingle) - } - return vSingle + conf = conf.WithParticipantExceptions(single) + return splitParticipantValues(conf, vSingle) } func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string { diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index 71cb9c1f2..ec66e12b9 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -4,6 +4,8 @@ import ( "os" "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" @@ -563,6 +565,37 @@ var _ = Describe("Participants", func() { matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"), )) }) + + It("should split multiple names in a single value", func() { + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"Eric Clapton/B.B. King"}, + "PERFORMER:BASS": {"Nathan East"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3))) + + p := participants[model.RolePerformer] + Expect(p).To(ContainElements( + matchPerformer("Eric Clapton", "eric clapton", "Guitar"), + matchPerformer("B.B. King", "b.b. king", "Guitar"), + matchPerformer("Nathan East", "nathan east", "Bass"), + )) + }) + + It("should assign MBIDs in order to names split from a single value", func() { + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"Eric Clapton/B.B. King"}, + "MUSICBRAINZ_PERFORMERID:GUITAR": {mbid1, mbid2}, + }) + + p := mf.Participants[model.RolePerformer] + Expect(p).To(HaveLen(2)) + Expect(p[0].Name).To(Equal("Eric Clapton")) + Expect(p[0].MbzArtistID).To(Equal(mbid1)) + Expect(p[1].Name).To(Equal("B.B. King")) + Expect(p[1].MbzArtistID).To(Equal(mbid2)) + }) }) When("MUSICBRAINZ_PERFORMERID tag is set", func() { @@ -684,6 +717,26 @@ var _ = Describe("Participants", func() { Expect(composers[2].Name).To(Equal("The Album Artist")) }) }) + + // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when + // multiple frames map to the same role tag (e.g. TIPL producer entries), + // the configured split separator must still apply to each value. + When("the tag has multiple values", func() { + It("should split each value individually", func() { + mf = toMediaFile(model.RawTags{ + "COMPOSER": {"John Doe/Jane Doe", "Someone Else"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) + composers := participants[model.RoleComposer] + Expect(composers).To(ConsistOf( + HaveField("Name", "John Doe"), + HaveField("Name", "Jane Doe"), + HaveField("Name", "Someone Else"), + )) + }) + }) }) Describe("MBID tags", func() { @@ -782,4 +835,60 @@ var _ = Describe("Participants", func() { } }) }) + + Describe("Artist split exceptions", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does not split a whitelisted artist name on the default separators", func() { + // " feat. " is a default artists separator (mappings.yaml) + conf.Server.Scanner.ArtistSplitExceptions = []string{"Someone feat. Else"} + mf = toMediaFile(model.RawTags{ + "ARTIST": {"Artist Name feat. Someone feat. Else"}, + }) + + artists := mf.Participants[model.RoleArtist] + Expect(artists).To(HaveLen(2)) + Expect(artists[0].Name).To(Equal("Artist Name")) + Expect(artists[1].Name).To(Equal("Someone feat. Else")) + }) + + It("does not split a whitelisted name in role tags", func() { + // "/" is a default roles separator (mappings.yaml) + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + mf = toMediaFile(model.RawTags{ + "COMPOSER": {"AC/DC/John Doe"}, + }) + + composers := mf.Participants[model.RoleComposer] + Expect(composers).To(HaveLen(2)) + Expect(composers[0].Name).To(Equal("AC/DC")) + Expect(composers[1].Name).To(Equal("John Doe")) + }) + + It("splits normally when the exception does not match", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + mf = toMediaFile(model.RawTags{ + "ARTIST": {"Artist Name feat. Someone Else"}, + }) + + artists := mf.Participants[model.RoleArtist] + Expect(artists).To(HaveLen(2)) + Expect(artists[0].Name).To(Equal("Artist Name")) + Expect(artists[1].Name).To(Equal("Someone Else")) + }) + + It("does not split a whitelisted name in performer tags", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"AC/DC/Brian Johnson"}, + }) + + performers := mf.Participants[model.RolePerformer] + Expect(performers).To(HaveLen(2)) + Expect(performers[0].Name).To(Equal("AC/DC")) + Expect(performers[1].Name).To(Equal("Brian Johnson")) + }) + }) }) diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 48928f989..729e83564 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -205,6 +205,7 @@ func clean(filePath string, tags model.RawTags) model.Tags { cleaned := make(model.Tags, len(mappings)) for name, mapping := range mappings { + mapping = mapping.WithParticipantExceptions(name) var values []string switch mapping.Type { case model.TagTypePair: diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 663e306c4..7ebe9fa4a 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -106,7 +105,7 @@ var _ = Describe("Metadata", func() { props.Tags = model.RawTags{ "Title": {strings.Repeat("a", 2048)}, "Comment": {strings.Repeat("a", 8192)}, - "lyrics:xxx": {strings.Repeat("a", 60000)}, + "lyrics:xxx": {strings.Repeat("a", 2_000_000)}, } md = metadata.New(filePath, props) @@ -117,9 +116,10 @@ var _ = Describe("Metadata", func() { Expect(pair).To(HaveLen(1)) Expect(pair[0].Key()).To(Equal("xxx")) + // Lyrics keep a much larger cap so word-timed karaoke survives. // Note: a total of 6 characters are lost from maxLength from - // the key portion and separator - Expect(pair[0].Value()).To(HaveLen(32762)) + // the key portion and separator. + Expect(pair[0].Value()).To(HaveLen(1048570)) }) It("should split multiple values", func() { @@ -130,6 +130,21 @@ var _ = Describe("Metadata", func() { Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"})) }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by + // TagLib's PropertyMap as a single "mood" key with multiple values. The split + // configuration must still apply to each value individually. + It("should split values from multiple frames mapping to the same tag", func() { + props.Tags = model.RawTags{ + // Same shape as the bug report: two frames, comma-separated content. + "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"}, + } + md = metadata.New(filePath, props) + + Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad")) + }) }) DescribeTable("Date", @@ -274,8 +289,8 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.2dB", "1.2dB", gg.P(1.2)), + Entry("0", "0", new(0.0)), + Entry("1.2dB", "1.2dB", new(1.2)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), Entry("NaN", "NaN", nil), @@ -285,9 +300,9 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.0", "1.0", gg.P(1.0)), - Entry("0.5", "0.5", gg.P(0.5)), + Entry("0", "0", new(0.0)), + Entry("1.0", "1.0", new(1.0)), + Entry("0.5", "0.5", new(0.5)), Entry("Invalid dB suffix", "0.7dB", nil), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), @@ -299,8 +314,8 @@ var _ = Describe("Metadata", func() { Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(5.0)), - Entry("-3776", "-3776", gg.P(-9.75)), + Entry("0", "0", new(5.0)), + Entry("-3776", "-3776", new(-9.75)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), ) diff --git a/model/playlist.go b/model/playlist.go index dc549f039..262774aa7 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -10,6 +10,8 @@ import ( ) type Playlist struct { + Annotations `structs:"-"` + ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` Comment string `structs:"comment" json:"comment"` @@ -121,6 +123,7 @@ type Playlists []Playlist type PlaylistRepository interface { ResourceRepository + AnnotatedRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) Put(pls *Playlist, cols ...string) error diff --git a/model/radio_test.go b/model/radio_test.go index dc421454e..860331f17 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -26,7 +26,7 @@ var _ = Describe("Radio", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/model/scrobble.go b/model/scrobble.go index e1567abc3..a8022fc16 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -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 diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index c75a82853..43ee2cc01 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -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 } diff --git a/model/tag.go b/model/tag.go index 1f6b24d21..02ccac05d 100644 --- a/model/tag.go +++ b/model/tag.go @@ -192,6 +192,10 @@ const ( TagISRC TagName = "isrc" TagBPM TagName = "bpm" TagExplicitStatus TagName = "explicitstatus" + TagWork TagName = "work" + TagMovementName TagName = "movementname" + TagMovementNumber TagName = "movement" + TagMovementTotal TagName = "movementtotal" // Dates and years @@ -240,6 +244,7 @@ const ( TagMusicBrainzAlbumArtistID TagName = "musicbrainz_albumartistid" TagMusicBrainzAlbumID TagName = "musicbrainz_albumid" TagMusicBrainzReleaseGroupID TagName = "musicbrainz_releasegroupid" + TagMusicBrainzWorkID TagName = "musicbrainz_workid" TagMusicBrainzComposerID TagName = "musicbrainz_composerid" TagMusicBrainzLyricistID TagName = "musicbrainz_lyricistid" diff --git a/model/tag_mappings.go b/model/tag_mappings.go index bfe098f77..ce7d2f37b 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -7,9 +7,11 @@ import ( "slices" "strings" "sync" + "sync/atomic" + "unicode" + "unicode/utf8" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/resources" @@ -26,31 +28,155 @@ type mappingsConf struct { type tagMappings map[TagName]TagConf type TagConf struct { - Aliases []string `yaml:"aliases"` - Type TagType `yaml:"type"` - MaxLength int `yaml:"maxLength"` - Split []string `yaml:"split"` - Album bool `yaml:"album"` - SplitRx *regexp.Regexp `yaml:"-"` + Aliases []string `yaml:"aliases"` + Type TagType `yaml:"type"` + MaxLength int `yaml:"maxLength"` + Split []string `yaml:"split"` + Album bool `yaml:"album"` + SplitRx *regexp.Regexp `yaml:"-"` + ExceptionsRx *regexp.Regexp `yaml:"-"` } -// SplitTagValue splits a tag value by the split separators, but only if it has a single value. +// SplitTagValue splits tag values by the configured split separators. +// Each value in the input slice is individually split and trimmed. func (c TagConf) SplitTagValue(values []string) []string { - // If there's not exactly one value or no separators, return early. - if len(values) != 1 || c.SplitRx == nil { + if c.SplitRx == nil || len(values) == 0 { return values } - tag := values[0] - // Replace all occurrences of any separator with the zero-width space. - tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) - - // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for i, part := range parts { - parts[i] = strings.TrimSpace(part) + var result []string + for _, tag := range values { + result = append(result, c.splitValue(tag)...) } - return parts + return result +} + +func (c TagConf) splitValue(tag string) []string { + protected := protectedSpans(tag, c.ExceptionsRx) + var parts []string + start := 0 + for _, sep := range c.SplitRx.FindAllStringIndex(tag, -1) { + if overlapsAny(sep, protected) { + continue + } + parts = append(parts, strings.TrimSpace(tag[start:sep[0]])) + start = sep[1] + } + return append(parts, strings.TrimSpace(tag[start:])) +} + +// protectedSpans returns the spans of rx matches that sit on word boundaries. +// Boundaries are checked here, rune-aware, because RE2's \b is ASCII-only and +// would silently never match names starting/ending with accented letters. +func protectedSpans(tag string, rx *regexp.Regexp) [][]int { + if rx == nil { + return nil + } + var spans [][]int + for _, span := range rx.FindAllStringIndex(tag, -1) { + if isWordBounded(tag, span[0], span[1]) { + spans = append(spans, span) + } + } + return spans +} + +func isWordBounded(s string, start, end int) bool { + isWord := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } + before, _ := utf8.DecodeLastRuneInString(s[:start]) + after, _ := utf8.DecodeRuneInString(s[end:]) + return !isWord(before) && !isWord(after) +} + +func overlapsAny(span []int, spans [][]int) bool { + for _, s := range spans { + if span[0] < s[1] && s[0] < span[1] { + return true + } + } + return false +} + +// compileExceptionsRegex builds a case-insensitive regex matching any of the +// given literal names, or nil if there are none. +func compileExceptionsRegex(exceptions []string) *regexp.Regexp { + var names []string + for _, e := range exceptions { + if e = strings.TrimSpace(e); e != "" { + names = append(names, e) + } + } + if len(names) == 0 { + return nil + } + // Longest-first: Go regex alternation is leftmost-first, so with overlapping + // entries (e.g. "Iron and Wine Duo" vs "Iron and Wine") the longer name must + // come first to win. Ties broken lexicographically for determinism. + slices.SortFunc(names, func(a, b string) int { + if c := cmp.Compare(len(b), len(a)); c != 0 { + return c + } + return cmp.Compare(a, b) + }) + escaped := make([]string, len(names)) + for i, name := range names { + escaped[i] = regexp.QuoteMeta(name) + } + rx, err := regexp.Compile("(?i)(" + strings.Join(escaped, "|") + ")") + if err != nil { + log.Warn("Error compiling split exceptions regexp", "exceptions", exceptions, err) + return nil + } + return rx +} + +type artistSplitExceptionsCache struct { + names []string + rx *regexp.Regexp +} + +var artistSplitExceptions atomic.Pointer[artistSplitExceptionsCache] + +// artistSplitExceptionsRx returns the regex for Scanner.ArtistSplitExceptions, +// or nil if none are configured. Compiled lazily (config hooks only run once +// per process, before tests can override the option) and cached until the +// configured list changes. Lock-free on the cache-hit path, as this is called +// per tag mapping per scanned file, across concurrent scanner goroutines. +func artistSplitExceptionsRx() *regexp.Regexp { + names := conf.Server.Scanner.ArtistSplitExceptions + if c := artistSplitExceptions.Load(); c != nil && slices.Equal(c.names, names) { + return c.rx + } + c := &artistSplitExceptionsCache{names: slices.Clone(names), rx: compileExceptionsRegex(names)} + artistSplitExceptions.Store(c) + return c.rx +} + +// participantTagNames are the tags that hold artist names (or their sort +// values), where split exceptions apply. +var participantTagNames = sync.OnceValue(func() map[TagName]struct{} { + names := []TagName{ + TagTrackArtist, TagTrackArtists, TagTrackArtistSort, TagTrackArtistsSort, + TagAlbumArtist, TagAlbumArtists, TagAlbumArtistSort, TagAlbumArtistsSort, + } + set := make(map[TagName]struct{}, len(names)+2*len(AllRoles)) + for _, n := range names { + set[n] = struct{}{} + } + for role := range AllRoles { + set[TagName(role)] = struct{}{} + set[TagName(role+"sort")] = struct{}{} + } + return set +}) + +// WithParticipantExceptions returns the conf with the global artist split +// exceptions attached when name is a participant (artist/role) tag. +func (c TagConf) WithParticipantExceptions(name TagName) TagConf { + if _, ok := participantTagNames()[name]; ok { + c.ExceptionsRx = artistSplitExceptionsRx() + } + return c } type TagType string diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go new file mode 100644 index 000000000..e582c3f2f --- /dev/null +++ b/model/tag_mappings_test.go @@ -0,0 +1,194 @@ +package model + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TagConf", func() { + Describe("SplitTagValue", func() { + var conf TagConf + + BeforeEach(func() { + conf = TagConf{Split: []string{";", "/", ","}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + }) + + It("splits a single value on configured separators", func() { + Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"})) + }) + + It("trims whitespace around split values", func() { + Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"})) + }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD), + // TagLib's PropertyMap merges them into a slice with several entries. Previously + // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case. + It("splits each value individually when given multiple inputs", func() { + input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"} + Expect(conf.SplitTagValue(input)).To(Equal([]string{ + "Love", "Emotional", "Ballad", + "Love", "Emotional", "Ballad", + })) + }) + + It("matches separators case-insensitively when the split pattern allows", func() { + c := TagConf{Split: []string{" AND "}} + c.SplitRx = compileSplitRegex("test", c.Split) + Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"})) + }) + + It("returns values unchanged when no separators are configured", func() { + c := TagConf{} + Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"})) + Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"})) + }) + + It("returns an empty slice for empty input", func() { + Expect(conf.SplitTagValue([]string{})).To(BeEmpty()) + }) + + It("handles a value with no separator as a single-element result", func() { + Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"})) + }) + + It("produces empty strings when separators are adjacent (dedup happens downstream)", func() { + // SplitTagValue itself does not filter empties; that is the job of + // filterDuplicatedOrEmptyValues in the metadata pipeline. + Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) + }) + + Context("with split exceptions", func() { + BeforeEach(func() { + conf = TagConf{Split: []string{" and ", ";", "/"}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + conf.ExceptionsRx = compileExceptionsRegex([]string{ + "Iron and Wine", + "Iron and Wine Duo", + "Ella and Louis", + "AC/DC", + "Ólafur Arnalds and Nils Frahm", + }) + }) + + It("does not split a value that is exactly an exception", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine"})).To(Equal([]string{"Iron and Wine"})) + }) + + It("protects an exception embedded in a multi-artist value", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine and Bob"})). + To(Equal([]string{"Iron and Wine", "Bob"})) + }) + + It("protects every occurrence, not just the first", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine; Bob; Iron and Wine"})). + To(Equal([]string{"Iron and Wine", "Bob", "Iron and Wine"})) + }) + + It("matches exceptions case-insensitively and keeps the tag's casing", func() { + Expect(conf.SplitTagValue([]string{"IRON AND WINE and Bob"})). + To(Equal([]string{"IRON AND WINE", "Bob"})) + }) + + It("prefers the longest exception when entries overlap", func() { + Expect(conf.SplitTagValue([]string{"Iron and Wine Duo and Bob"})). + To(Equal([]string{"Iron and Wine Duo", "Bob"})) + }) + + It("protects exceptions containing separator characters", func() { + Expect(conf.SplitTagValue([]string{"AC/DC/Queen"})). + To(Equal([]string{"AC/DC", "Queen"})) + }) + + It("does not protect an exception embedded in a longer word", func() { + // "Ella and Louis" must not match inside "Ella and Louise" + Expect(conf.SplitTagValue([]string{"Ella and Louise"})). + To(Equal([]string{"Ella", "Louise"})) + }) + + It("handles names with non-ASCII edges", func() { + Expect(conf.SplitTagValue([]string{"Ólafur Arnalds and Nils Frahm and Bob"})). + To(Equal([]string{"Ólafur Arnalds and Nils Frahm", "Bob"})) + }) + + It("splits normally when no exception matches", func() { + Expect(conf.SplitTagValue([]string{"Foo and Bar"})).To(Equal([]string{"Foo", "Bar"})) + }) + }) + }) + + Describe("compileExceptionsRegex", func() { + It("returns nil for an empty list", func() { + Expect(compileExceptionsRegex(nil)).To(BeNil()) + Expect(compileExceptionsRegex([]string{})).To(BeNil()) + }) + + It("returns nil when all entries are blank", func() { + Expect(compileExceptionsRegex([]string{"", " "})).To(BeNil()) + }) + + It("escapes regex metacharacters in names", func() { + rx := compileExceptionsRegex([]string{"Sigur (Rós)"}) + Expect(rx.FindString("Sigur (Rós)")).To(Equal("Sigur (Rós)")) + Expect(rx.MatchString("Sigur xRósx")).To(BeFalse()) + }) + + It("matches case-insensitively", func() { + rx := compileExceptionsRegex([]string{"Iron and Wine"}) + Expect(rx.MatchString("IRON AND WINE")).To(BeTrue()) + }) + }) + + Describe("artistSplitExceptionsRx", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns nil when no exceptions are configured", func() { + conf.Server.Scanner.ArtistSplitExceptions = nil + Expect(artistSplitExceptionsRx()).To(BeNil()) + }) + + It("compiles the configured exceptions", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + rx := artistSplitExceptionsRx() + Expect(rx).ToNot(BeNil()) + Expect(rx.MatchString("iron and wine")).To(BeTrue()) + }) + + It("caches the compiled regex until the configuration changes", func() { + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + first := artistSplitExceptionsRx() + Expect(artistSplitExceptionsRx()).To(BeIdenticalTo(first)) + + conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} + second := artistSplitExceptionsRx() + Expect(second).ToNot(BeIdenticalTo(first)) + Expect(second.MatchString("AC/DC")).To(BeTrue()) + }) + }) + + Describe("WithParticipantExceptions", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} + }) + + It("attaches the exceptions regex to participant tags", func() { + for _, tag := range []TagName{"artist", "albumartist", "artists", "artistsort", "composer", "lyricist", "composersort"} { + Expect(TagConf{}.WithParticipantExceptions(tag).ExceptionsRx).ToNot(BeNil(), string(tag)) + } + }) + + It("does not attach the exceptions regex to non-participant tags", func() { + for _, tag := range []TagName{"genre", "mood", "title", "releasetype"} { + Expect(TagConf{}.WithParticipantExceptions(tag).ExceptionsRx).To(BeNil(), string(tag)) + } + }) + }) +}) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 99ed10877..34845be15 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -69,7 +70,7 @@ func (a *dbAlbum) PostMapArgs(args map[string]any) error { fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...) args["full_text"] = formatFullText(fullText...) args["search_participants"] = strings.Join(participantNames, " ") - args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist) + args["search_normalized"] = str.NormalizeForFTS(a.Name, a.AlbumArtist) args["tags"] = marshalTags(a.Album.Tags) args["participants"] = marshalParticipants(a.Album.Participants) @@ -143,9 +144,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "datetime(album.updated_at)" + return "album.updated_at, album.id" } - return "datetime(album.created_at)" + return "album.created_at, album.id" } func recentlyPlayedFilter(string, any) Sqlizer { @@ -186,8 +187,10 @@ func allRolesFilter(_ string, value any) Sqlizer { func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() - query = r.withAnnotation(query, "album.id") query = r.applyLibraryFilter(query) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "album.id") + } return r.count(query, options...) } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index a6270933f..f72f778db 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -112,49 +112,66 @@ var _ = Describe("AlbumRepository", func() { }) Describe("recently_added sort", func() { - It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() { - // Both timestamps share the same date prefix "2024-01-15" so the T vs space - // character at position 10 determines sort order in raw string comparison. - // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older - // T-format timestamp sort AFTER the newer space-format one. + AfterEach(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album"). + Where(squirrel.Like{"id": "ra-%"})) + }) - // Older album: morning of Jan 15, stored in T-format - olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"} - Expect(albumRepo.Put(olderAlbum)).To(Succeed()) + // Sub-second precision must survive, and ties must break deterministically + // so the order is independent of any filter (issue #5673). + indexOf := func(albums model.Albums, id string) int { + for i, a := range albums { + if a.ID == id { + return i + } + } + return -1 + } + + It("orders by sub-second precision, not truncated to the second", func() { + // Same second, different nanoseconds: datetime() would tie these. + earlier := &model.Album{LibraryID: 1, ID: "ra-earlier", Name: "Earlier"} + later := &model.Album{LibraryID: 1, ID: "ra-later", Name: "Later"} + Expect(albumRepo.Put(earlier)).To(Succeed()) + Expect(albumRepo.Put(later)).To(Succeed()) _, err := albumRepo.executeSQL(squirrel.Update("album"). - Set("created_at", "2024-01-15T08:00:00Z"). - Where(squirrel.Eq{"id": "ts-older"})) + Set("created_at", "2024-01-15 10:00:00.100000000+00:00"). + Where(squirrel.Eq{"id": "ra-earlier"})) Expect(err).ToNot(HaveOccurred()) - - // Newer album: evening of Jan 15, stored in space-format - newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"} - Expect(albumRepo.Put(newerAlbum)).To(Succeed()) _, err = albumRepo.executeSQL(squirrel.Update("album"). - Set("created_at", "2024-01-15 20:00:00+00:00"). - Where(squirrel.Eq{"id": "ts-newer"})) + Set("created_at", "2024-01-15 10:00:00.900000000+00:00"). + Where(squirrel.Eq{"id": "ra-later"})) Expect(err).ToNot(HaveOccurred()) albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) Expect(err).ToNot(HaveOccurred()) + Expect(indexOf(albums, "ra-later")).To(BeNumerically("<", indexOf(albums, "ra-earlier")), + ".900 should sort before .100 in desc order") + }) - // Find positions of our test albums - olderIdx, newerIdx := -1, -1 - for i, a := range albums { - switch a.ID { - case "ts-older": - olderIdx = i - case "ts-newer": - newerIdx = i - } + It("breaks ties deterministically and consistently across filters", func() { + // All sharing one created_at: the relative order of any subset must + // match the unfiltered order (the inversion mechanism in #5673). + ids := []string{"ra-t1", "ra-t2", "ra-t3", "ra-t4"} + for _, aid := range ids { + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) } - Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results") - Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results") - // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order - Expect(newerIdx).To(BeNumerically("<", olderIdx), - "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order") + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-02-20 12:00:00+00:00"). + Where(squirrel.Eq{"id": ids})) + Expect(err).ToNot(HaveOccurred()) - // Clean up - _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) + all, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + subset, err := albumRepo.GetAll(model.QueryOptions{ + Sort: "recently_added", Order: "desc", + Filters: squirrel.Eq{"album.id": []string{"ra-t1", "ra-t3"}}}) + Expect(err).ToNot(HaveOccurred()) + + Expect(indexOf(all, "ra-t1") < indexOf(all, "ra-t3")). + To(Equal(indexOf(subset, "ra-t1") < indexOf(subset, "ra-t3")), + "tied albums must keep the same relative order with and without a filter") }) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index e75a0e58c..f84f410e9 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -18,8 +18,8 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -103,8 +103,10 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error { } similarArtists, _ := json.Marshal(sa) m["similar_artists"] = string(similarArtists) + // When adding a derived column here, also add it to the scanner's artist Put column list + // in phase_1_folders.go, or rescans will never update it (how search_normalized went stale). m["full_text"] = formatFullText(a.Name, a.SortArtistName) - m["search_normalized"] = normalizeForFTS(a.Name) + m["search_normalized"] = str.NormalizeForFTS(a.Name) // Do not override the sort_artist_name and mbz_artist_id fields if they are empty // TODO: Better way to handle this? @@ -202,7 +204,11 @@ func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBui func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() query = r.applyLibraryFilterToArtistQuery(query) - query = r.withAnnotation(query, "artist.id") + // Only the annotation join is gated; the library_artist join above (and its count(distinct)) + // must stay, since an artist can span multiple libraries. + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "artist.id") + } return r.count(query, options...) } @@ -219,7 +225,7 @@ func (r *artistRepository) Exists(id string) (bool, error) { func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} - dba.CreatedAt = P(time.Now()) + dba.CreatedAt = new(time.Now()) dba.UpdatedAt = dba.CreatedAt _, err := r.put(dba.ID, dba, colsToUpdate...) return err @@ -354,6 +360,19 @@ func (r *artistRepository) purgeEmpty() error { return nil } +// markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the +// search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can +// be dropped: RefreshStats cleanup and library deletion cascade. +func (r *artistRepository) markOrphansMissing() error { + _, err := r.executeSQL(Expr( + "update artist set missing = true where missing = false " + + "and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)")) + if err != nil { + return fmt.Errorf("marking orphaned artists missing: %w", err) + } + return nil +} + // markMissing marks artists as missing if all their albums are missing. func (r *artistRepository) markMissing() error { q := Expr(` @@ -528,42 +547,130 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { totalRowsAffected += rowsAffected } - // // Remove library_artist entries for artists that no longer have any content in any library + // Remove library_artist entries for artists that no longer have any content in a library. cleanupSQL := Delete("library_artist").Where("stats = '{}'") cleanupRows, err := r.executeSQL(cleanupSQL) if err != nil { - log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err) - } else if cleanupRows > 0 { - log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", err) + } else { + if cleanupRows > 0 { + log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + } + // Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan + // also heals any left by older versions. + if cleanupRows > 0 || allArtists { + if err := r.markOrphansMissing(); err != nil { + log.Warn(r.ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) + } + } } log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } -func (r *artistRepository) searchCfg() searchConfig { +// searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must +// restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope]. +func (r *artistRepository) searchCfg(scope []int) searchConfig { return searchConfig{ // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist - NaturalOrder: "artist.id", - OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, - MBIDFields: []string{"mbz_artist_id"}, - LibraryFilter: r.applyLibraryFilterToArtistQuery, + NaturalOrder: "artist.id", + OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, + MBIDFields: []string{"mbz_artist_id"}, + // scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing). + // Otherwise the join-free [artistLibraryFilter]. + LibraryFilter: func(query SelectBuilder) SelectBuilder { + if scope == nil { + return query + } + return query.Where(artistLibraryFilter(scope)) + }, } } +// artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the +// library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN +// would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops +// SQLite from flattening the EXISTS back into a fan-out join, while still using the +// (library_id, artist_id) UNIQUE autoindex. +func artistLibraryFilter(libraryIDs []int) Sqlizer { + if len(libraryIDs) == 0 { + return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery + } + sub, args, _ := Select("1").From("library_artist"). + Where(And{ + Expr("library_artist.artist_id = artist.id"), + Eq{"library_artist.library_id": libraryIDs}, + }).Limit(1).ToSql() + return Expr("EXISTS ("+sub+")", args...) +} + func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } + // Artists have no library_id column, so the library_id filter callers pass (same as albums/songs) + // can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg). + scope := r.searchScope(opts.Filters) + if isLibraryIDFilter(opts.Filters) { + opts.Filters = nil + } var res dbArtists - err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts) + err := r.doSearch(r.selectArtist(opts), q, &res, r.searchCfg(scope), opts) if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } return res.toModels(), nil } +// searchScope returns the library IDs the search must be restricted to, or nil to skip the filter +// entirely (the fast-path: the user sees everything the search could return, so a filter would be +// pure O(offset) overhead). It intersects the requested libraries with what the user can see. +func (r *artistRepository) searchScope(filter Sqlizer) []int { + visible, err := r.visibleLibraryIDs() + if err != nil { + return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen + } + requested := r.requestedLibraryIDs(filter) + if requested == nil { + // No explicit request: scope to the visible set, unless the user sees everything. + if r.userSeesAllLibraries(visible) { + return nil + } + return visible + } + // Narrow unless the request already covers everything the user can see. Compare by membership, + // not length: the requested IDs may contain duplicates. + requestedSet := slice.ToSet(requested) + if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) { + return requested + } + return nil +} + +// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is +// not that shape. +func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int { + eq, ok := filter.(Eq) + if !ok { + return nil + } + ids, _ := eq["library_id"].([]int) + return ids +} + +// isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can +// consume it before it reaches the bare artist table (which has no library_id column). +func isLibraryIDFilter(filter Sqlizer) bool { + eq, ok := filter.(Eq) + if !ok { + return false + } + _, ok = eq["library_id"] + return ok +} + func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index e2904466c..d7b695ade 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "fmt" "os" "path/filepath" @@ -110,6 +111,80 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("searchScope", func() { + // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), + // the way Search() does, for a repo whose context carries the given user. + scope := func(user model.User, filter squirrel.Sqlizer) []int { + ctx := request.WithUser(GinkgoT().Context(), user) + r := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + return r.searchScope(filter) + } + subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}} + + It("scopes to a strict subset of the user's libraries", func() { + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2}})).To(Equal([]int{1, 2})) + }) + + It("treats duplicate IDs as a set so a real subset still narrows", func() { + // {1,1,2} has 3 entries but is a strict subset of the user's 3 libraries. + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 1, 2}})).To(Equal([]int{1, 1, 2})) + }) + + It("returns nil (fast-path) when the request covers all the user's libraries", func() { + Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2, 3}})).To(BeNil()) + }) + + It("scopes to the user's libraries when no library filter is given", func() { + // A restricted user (strictly fewer libs than exist) with no musicFolderId is still + // confined to their granted libs. Build the user with total-1 libraries derived from + // the real DB total, so the "sees all" fast-path can't kick in regardless of count. + total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">", 0)) + libs := make(model.Libraries, 0, total-1) + for i := int64(1); i < total; i++ { // total-1 distinct libraries → a strict subset + libs = append(libs, model.Library{ID: int(i)}) + } + restricted := model.User{ID: "r", Libraries: libs} + got := scope(restricted, nil) + Expect(got).To(HaveLen(int(total) - 1)) + }) + + It("returns nil (fast-path) for an admin requesting all existing libraries", func() { + // Admins see every library, so the visible set is the whole library table — derive + // it from the DB rather than assuming a count. + var allLibs []int + Expect(NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).(*libraryRepository). + queryAllSlice(squirrel.Select("id").From("library"), &allLibs)).To(Succeed()) + admin := model.User{ID: "a", IsAdmin: true} + Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil()) + Expect(scope(admin, nil)).To(BeNil()) + }) + + It("narrows for an admin explicitly requesting a subset via musicFolderId", func() { + // An admin scoping to a single, non-existent-as-the-whole-set library must still be + // narrowed (regression: search3?musicFolderId=lib2 was leaking lib1 content). + admin := model.User{ID: "a", IsAdmin: true} + Expect(scope(admin, squirrel.Eq{"library_id": []int{-1}})).To(Equal([]int{-1})) + }) + + It("returns nil for a non-library_id filter (no library scoping requested)", func() { + // Such a filter carries no library intent; for this fully-granted-style user the + // search needs no extra library restriction. + allUser := model.User{ID: "u2", IsAdmin: true} + Expect(scope(allUser, squirrel.Eq{"name": "x"})).To(BeNil()) + }) + + It("falls back to the visible scope for a malformed library_id value (no crash)", func() { + // A library_id filter whose value isn't []int is still recognized as a library + // filter (so Search consumes it and it never reaches the bare artist table), and + // searchScope falls back to exactly the no-filter behavior rather than crashing. + malformed := squirrel.Eq{"library_id": "not-a-slice"} + Expect(isLibraryIDFilter(malformed)).To(BeTrue()) + Expect(scope(subsetUser, malformed)).To(Equal(scope(subsetUser, nil))) + }) + }) + Describe("dbArtist mapping", func() { var ( artist *model.Artist @@ -198,6 +273,23 @@ var _ = Describe("ArtistRepository", func() { It("returns the number of artists in the DB", func() { Expect(repo.CountAll()).To(Equal(int64(4))) }) + + It("counts starred artists when an annotation filter is present", func() { + // The Beatles (id 3) is starred for the admin user in the seed data + count, err := repo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("counts with has_rating=false without a 'no such column' error (join kept)", func() { + count, err := repo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("rating")("rating", "false"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) }) Describe("Exists", func() { @@ -594,6 +686,98 @@ var _ = Describe("ArtistRepository", func() { }) }) + Context("Empty Query (sync pagination)", func() { + It("does not duplicate artists that belong to multiple libraries", func() { + // An artist in two libraries has two library_artist rows; pagination + // must still enumerate it exactly once, at a stable offset. + Expect(lr.AddArtist(lib2.ID, artistBeatles.ID)).To(Succeed()) + + all, err := repo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + seen := map[string]bool{} + var paged model.Artists + for offset := range len(all) { + page, err := repo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + for _, a := range page { + Expect(seen[a.ID]).To(BeFalse(), fmt.Sprintf("artist %s returned twice", a.ID)) + seen[a.ID] = true + } + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + }) + + It("paginates all artists in natural order without overlaps or gaps", func() { + all, err := repo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 1)) + + var paged model.Artists + pageSize := 2 + for offset := 0; offset < len(all); offset += pageSize { + page, err := repo.Search("", model.QueryOptions{Max: pageSize, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID)) + } + }) + + It("respects library filtering for restricted users", func() { + // Create an artist only in library 2 (not accessible to restricted user) + lib2Artist := model.Artist{ID: "empty-query-lib2-artist", Name: "Empty Query Lib2 Artist"} + Expect(repo.Put(&lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + + results, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + for _, a := range results { + Expect(a.ID).ToNot(Equal(lib2Artist.ID), "Empty query search should respect library filtering") + } + + // Clean up + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + } + }) + + It("paginates a restricted user's visible artists without gaps", func() { + // ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands + // inside the restricted user's visible range — exercising the no-gap guarantee. + lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"} + Expect(repo.Put(&lib2Artist)).To(Succeed()) + Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed()) + DeferCleanup(func() { + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) + } + }) + + all, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 1)) + for _, a := range all { + Expect(a.ID).ToNot(Equal(lib2Artist.ID)) + } + + var paged model.Artists + for offset := range len(all) { + page, err := restrictedRepo.Search("", model.QueryOptions{Max: 1, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset)) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID)) + } + }) + }) + Context("Headless Processes (No User Context)", func() { It("should see all artists from all libraries when no user is in context", func() { // Add artists to different libraries @@ -830,6 +1014,45 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) + + It("takes the unfiltered fast-path when the user can access every library", func() { + // The fixture DB has a single library and the user was granted it, so it has access + // to all libraries: search results must match what an admin sees. + adminRepo := NewArtistRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + adminAll, err := adminRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + userAll, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + + ids := func(artists model.Artists) []string { + out := make([]string, len(artists)) + for i, a := range artists { + out[i] = a.ID + } + return out + } + Expect(ids(userAll)).To(Equal(ids(adminAll))) + Expect(userAll).ToNot(BeEmpty()) + }) + + It("detects all-library access regardless of result equivalence", func() { + // userSeesAllLibraries drives the search fast-path for a non-admin: true when the + // visible-library count reaches the DB total. Derive the total from the DB so the + // assertion doesn't depend on how many libraries other specs left behind. + raw := restrictedRepo.(*artistRepository) // context carries a non-admin user + total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">", 0)) + + allLibs := make([]int, total) + for i := range allLibs { + allLibs[i] = i + 1 + } + Expect(raw.userSeesAllLibraries(allLibs)).To(BeTrue()) + Expect(raw.userSeesAllLibraries(allLibs[:total-1])).To(BeFalse()) + Expect(raw.userSeesAllLibraries([]int{})).To(BeFalse()) + }) }) }) @@ -840,7 +1063,7 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) ctx := request.WithUser(GinkgoT().Context(), adminUser) repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) @@ -915,6 +1138,66 @@ var _ = Describe("ArtistRepository", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Describe("RefreshStats", func() { + var repo *artistRepository + + missing := func(id string) bool { + var vals []bool + Expect(repo.queryAllSlice(squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed()) + Expect(vals).To(HaveLen(1)) + return vals[0] + } + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + }) + + It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() { + // A library_artist row with stats '{}' (no content) gets deleted by the cleanup, + // which would orphan this non-missing artist. + emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"} + Expect(repo.Put(&emptyArtist)).To(Succeed()) + _, err := repo.executeSQL(squirrel.Insert("library_artist"). + SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"})) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _, _ = repo.executeSQL(squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) + _ = repo.delete(squirrel.Eq{"id": emptyArtist.ID}) + }) + + Expect(missing(emptyArtist.ID)).To(BeFalse()) + + _, err = repo.RefreshStats(true) + Expect(err).ToNot(HaveOccurred()) + + Expect(missing(emptyArtist.ID)).To(BeTrue()) + var orphanIDs []string + Expect(repo.queryAllSlice(squirrel.Select("id").From("artist"). + Where("missing = false"). + Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed()) + Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID)) + }) + + It("heals a pre-existing orphan (no library_artist row) on a full refresh", func() { + // A legacy orphan left by an older version: non-missing, with no library_artist row at + // all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still + // reconcile it. + legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"} + Expect(repo.Put(&legacyOrphan)).To(Succeed()) + DeferCleanup(func() { + _ = repo.delete(squirrel.Eq{"id": legacyOrphan.ID}) + }) + + Expect(missing(legacyOrphan.ID)).To(BeFalse()) + + _, err := repo.RefreshStats(true) + Expect(err).ToNot(HaveOccurred()) + + Expect(missing(legacyOrphan.ID)).To(BeTrue()) + }) + }) }) // Helper function to create an artist with proper library association. diff --git a/persistence/collation_test.go b/persistence/collation_test.go index bb1276577..dff91148e 100644 --- a/persistence/collation_test.go +++ b/persistence/collation_test.go @@ -50,9 +50,6 @@ var _ = Describe("Collation", func() { Entry("media_file.order_title", "media_file", "order_title collate nocase"), Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"), Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"), - Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"), - Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"), - Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"), Entry("media_file.path", "media_file", "path collate nocase"), Entry("playlist.name", "playlist", "name collate nocase"), Entry("radio.name", "radio", "name collate nocase"), diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index a1bae3170..43cab4fdd 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -3,7 +3,9 @@ package persistence import ( "errors" "fmt" + "maps" "reflect" + "slices" "strconv" "strings" "time" @@ -26,9 +28,11 @@ func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { } type smartPlaylistField struct { - expr string - order string - joinType smartPlaylistJoinType + expr string + order string + joinType smartPlaylistJoinType + emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) + coalesceDefault any // missing-row default for a nullable annotation column; nil = none. See annotationCond. } type smartPlaylistCriteria struct { @@ -70,7 +74,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "datemodified": {expr: "media_file.updated_at"}, "discsubtitle": {expr: "media_file.disc_subtitle"}, "comment": {expr: "media_file.comment"}, - "lyrics": {expr: "media_file.lyrics"}, + "lyrics": {expr: "media_file.lyrics", emptyValues: []string{"[]"}}, "sorttitle": {expr: "media_file.sort_title"}, "sortalbum": {expr: "media_file.sort_album_name"}, "sortartist": {expr: "media_file.sort_artist_name"}, @@ -86,22 +90,22 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "samplerate": {expr: "media_file.sample_rate"}, "bpm": {expr: "media_file.bpm"}, "channels": {expr: "media_file.channels"}, - "loved": {expr: "COALESCE(annotation.starred, false)"}, + "loved": {expr: "annotation.starred", coalesceDefault: false}, "dateloved": {expr: "annotation.starred_at"}, "lastplayed": {expr: "annotation.play_date"}, "daterated": {expr: "annotation.rated_at"}, - "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, - "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "playcount": {expr: "annotation.play_count", coalesceDefault: 0}, + "rating": {expr: "annotation.rating", coalesceDefault: 0}, "averagerating": {expr: "media_file.average_rating"}, - "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumrating": {expr: "album_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "album_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "album_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, - "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, - "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, - "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, @@ -137,7 +141,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } and = append(and, cond) } - return and, nil + return mergeNegatedJsonConds(and), nil case criteria.Any: or := squirrel.Or{} for _, child := range e { @@ -147,29 +151,19 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } or = append(or, cond) } - return or, nil + return mergeJsonConds(or), nil case criteria.Is: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Eq(fields) - }, false) + return comparisonExpr(e, cmpEq) case criteria.IsNot: return isNotExpr(e) case criteria.Gt: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Gt(fields) - }, false) + return comparisonExpr(e, cmpGt) case criteria.Lt: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Lt(fields) - }, false) + return comparisonExpr(e, cmpLt) case criteria.Before: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Lt(fields) - }, false) + return comparisonExpr(e, cmpLt) case criteria.After: - return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { - return squirrel.Gt(fields) - }, false) + return comparisonExpr(e, cmpGt) case criteria.Contains: return likeExpr(e, "%%%v%%", false) case criteria.NotContains: @@ -201,11 +195,7 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Eq{"value": value}, true), nil } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return squirrel.NotEq(fields), nil + return comparisonExpr(values, cmpNe) } func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { @@ -216,32 +206,57 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er } return nil, fmt.Errorf("invalid field in criteria: %s", field) } - if !info.IsTag && !info.IsRole { - return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) - } - b, ok := value.(bool) if !ok { return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) } negate := checkAbsence == b - return jsonExpr(info, nil, negate), nil -} -func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { - return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil + switch { + case info.IsTag || info.IsRole: + return jsonExpr(info, nil, negate), nil + case info.Nullable: + // Nullable column fields are stored in dedicated columns, not in the tags JSON, so + // "missing" maps to a column check rather than a json_tree lookup. Numeric/boolean + // columns (e.g. ReplayGain, BPM) encode absence as NULL only; string columns (e.g. + // mbz_* IDs, lyrics) additionally treat empty string — and any field-specific empty + // encodings (e.g. '[]' for lyrics) — as missing. The unified flow below handles both: + // numeric/boolean fields simply have no empties, so the loops are no-ops. + f, ok := smartPlaylistFields[info.Name()] + if !ok || f.expr == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + col := f.expr + var empties []string + if !info.Numeric && !info.Boolean { + empties = append([]string{""}, f.emptyValues...) + } + missing := squirrel.Or{squirrel.Eq{col: nil}} + present := squirrel.And{squirrel.NotEq{col: nil}} + for _, e := range empties { + missing = append(missing, squirrel.Eq{col: e}) + present = append(present, squirrel.NotEq{col: e}) + } + if negate { + return missing, nil + } + return present, nil + default: + return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field) } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return makeCond(fields), nil } func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { - return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + if _, value, info, ok := singleField(values); ok { + if info.IsTag || info.IsRole { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + // LIKE can't use the column index, so annotation fields keep the COALESCE form: a NULL + // column never matches LIKE, which would silently drop missing-annotation rows the original + // COALESCE form included. + if f, isAnnotation := annotationField(info); isAnnotation { + return likeCond(f.coalesced(), fmt.Sprintf(pattern, value), negate), nil + } } fields, err := sqlFields(values) if err != nil { @@ -261,23 +276,36 @@ func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqli return lk, nil } +func likeCond(col, pattern string, negate bool) squirrel.Sqlizer { + if negate { + return squirrel.NotLike{col: pattern} + } + return squirrel.Like{col: pattern} +} + func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { - fields, err := sqlFields(values) + field, value, info, ok := singleField(values) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + // Tags/roles are multi-valued JSON, so splitting a range into two independent EXISTS + // subqueries would let different values satisfy each bound. Ranges are unsupported there. + return nil, fmt.Errorf("range operator not supported for tag/role field: %s", field) + } + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("range criteria for %q must be a [min, max] pair, got: %v", field, value) + } + low, err := comparisonExpr(map[string]any{field: s.Index(0).Interface()}, cmpGe) if err != nil { return nil, err } - and := squirrel.And{} - for field, value := range fields { - s := reflect.ValueOf(value) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) - } - and = append(and, - squirrel.GtOrEq{field: s.Index(0).Interface()}, - squirrel.LtOrEq{field: s.Index(1).Interface()}, - ) + high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe) + if err != nil { + return nil, err } - return and, nil + return squirrel.And{low, high}, nil } func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { @@ -381,17 +409,195 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { var cond string var args []any - var err error if e.cond != nil { - cond, args, err = e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + innerSQL, innerArgs, err := roleCondSQL(e.cond) + if err != nil { + return "", nil, err + } + cond = roleExistsSQL(innerSQL) + args = append([]any{e.role}, innerArgs...) } else { - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" + args = []any{e.role} } if e.not { cond = "not " + cond } - return cond, args, err + return cond, args, nil +} + +// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name. +func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) { + sql, args, err := cond.ToSql() + if err != nil { + return "", nil, err + } + return strings.ReplaceAll(sql, "value", "artist.name"), args, nil +} + +// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery. +func roleExistsSQL(innerCond string) string { + return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+ + "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond) +} + +// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery +// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper +// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum. +const jsonCondBatchSize = 350 + +// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same +// field within an OR group into batched EXISTS subqueries with the conditions ORed inside. +// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically +// improving performance for smart playlists with many patterns. +func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + if merged, ok := mergeSameFieldConds(or, false); ok { + return squirrel.Or(merged) + } + return or +} + +// mergeNegatedJsonConds is the AND-group counterpart to mergeJsonConds, merging negated +// conditions. By De Morgan, "NOT EXISTS(X) AND NOT EXISTS(Y)" == "NOT EXISTS(X OR Y)". +func mergeNegatedJsonConds(and squirrel.And) squirrel.Sqlizer { + if merged, ok := mergeSameFieldConds(and, true); ok { + return squirrel.And(merged) + } + return and +} + +// mergeSameFieldConds groups roleCond/tagCond entries that share a field and the requested +// polarity, replacing each group of 2+ with batched roleCondGroup/tagCondGroup subqueries. +// Returns the rewritten conditions and whether any merge happened. +func mergeSameFieldConds(conds []squirrel.Sqlizer, negated bool) ([]squirrel.Sqlizer, bool) { + type condEntry struct { + index int + cond squirrel.Sqlizer + } + type group struct { + entries []condEntry + isRole bool + numeric bool + tag string + } + groups := make(map[string]*group) + for i, s := range conds { + switch c := s.(type) { + case roleCond: + if c.not != negated || c.cond == nil { + continue + } + g, exists := groups["role:"+c.role] + if !exists { + g = &group{isRole: true} + groups["role:"+c.role] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + case tagCond: + if c.not != negated || c.cond == nil { + continue + } + g, exists := groups["tag:"+c.tag] + if !exists { + g = &group{tag: c.tag, numeric: c.numeric} + groups["tag:"+c.tag] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + } + } + + remove := make(map[int]bool) + var additions []squirrel.Sqlizer + for _, key := range slices.Sorted(maps.Keys(groups)) { + g := groups[key] + if len(g.entries) < 2 { + continue + } + batchConds := make([]squirrel.Sqlizer, len(g.entries)) + for i, e := range g.entries { + remove[e.index] = true + batchConds[i] = e.cond + } + if g.isRole { + role := key[len("role:"):] + for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch, not: negated}) + } + } else { + for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch, not: negated}) + } + } + } + + if len(remove) == 0 { + return conds, false + } + + result := make([]squirrel.Sqlizer, 0, len(conds)-len(remove)+len(additions)) + for i, s := range conds { + if !remove[i] { + result = append(result, s) + } + } + return append(result, additions...), true +} + +// roleCondGroup represents multiple role conditions for the same role, merged into a single +// (optionally negated) EXISTS subquery for performance. +type roleCondGroup struct { + role string + conds []squirrel.Sqlizer + not bool +} + +func (g roleCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + allArgs := []any{g.role} + for _, c := range g.conds { + part, args, err := roleCondSQL(c) + if err != nil { + return "", nil, err + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + if g.not { + cond = "not " + cond + } + return cond, allArgs, nil +} + +// tagCondGroup represents multiple tag conditions for the same tag, merged into a single +// (optionally negated) EXISTS subquery for performance. +type tagCondGroup struct { + tag string + numeric bool + conds []squirrel.Sqlizer + not bool +} + +func (g tagCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + var allArgs []any + for _, c := range g.conds { + part, args, err := c.ToSql() + if err != nil { + return "", nil, err + } + if g.numeric { + part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)") + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", + g.tag, strings.Join(innerParts, " OR ")) + if g.not { + cond = "not " + cond + } + return cond, allArgs, nil } func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { @@ -429,6 +635,139 @@ func fieldExpr(name string) (string, bool) { return field.expr, ok } +// comparator is a scalar SQL comparison operator used by smart playlist criteria. +type comparator struct { + build func(map[string]any) squirrel.Sqlizer + satisfy func(a, b float64) bool // the operator as a predicate, to reason about a column's COALESCE default + // ordering is false only for = and <>, the only operators with a clean bare form over bool columns. + ordering bool +} + +var ( + cmpEq = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Eq(f) }, satisfy: func(a, b float64) bool { return a == b }} + cmpNe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.NotEq(f) }, satisfy: func(a, b float64) bool { return a != b }} + cmpGt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Gt(f) }, satisfy: func(a, b float64) bool { return a > b }, ordering: true} + cmpGe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.GtOrEq(f) }, satisfy: func(a, b float64) bool { return a >= b }, ordering: true} + cmpLt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Lt(f) }, satisfy: func(a, b float64) bool { return a < b }, ordering: true} + cmpLe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.LtOrEq(f) }, satisfy: func(a, b float64) bool { return a <= b }, ordering: true} +) + +// annotationField returns the field definition only for nullable annotation columns that have a +// COALESCE default (playcount, rating, loved). Date annotation columns (lastplayed, dateloved, ...) +// have no default and return ok=false. +func annotationField(info criteria.FieldInfo) (smartPlaylistField, bool) { + f, ok := smartPlaylistFields[info.Name()] + if !ok || f.coalesceDefault == nil { + return smartPlaylistField{}, false + } + return f, true +} + +// coalesced wraps the field in COALESCE(col, default) (bare expression if it has no default). Used +// where index-friendliness does not apply (ORDER BY, list comparisons) and the missing-row-as-default +// semantics must be kept. +func (f smartPlaylistField) coalesced() string { + if f.coalesceDefault == nil { + return f.expr + } + return fmt.Sprintf("COALESCE(%s, %s)", f.expr, sqlLiteral(f.coalesceDefault)) +} + +func comparisonExpr(values map[string]any, cmp comparator) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok { + if info.IsTag || info.IsRole { + return jsonExpr(info, cmp.build(map[string]any{"value": value}), false), nil + } + if f, isAnnotation := annotationField(info); isAnnotation { + return annotationCond(f, cmp, value), nil + } + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return cmp.build(fields), nil +} + +// annotationCond builds a comparison against a nullable annotation column (see smartPlaylistField +// for why). When bareNullInclusion can reason about the comparison exactly it emits the +// index-friendly bare `col ?`, adding `OR col IS NULL` only when the default would match; +// otherwise it falls back to the COALESCE form, which can't use the index but is always equivalent. +func annotationCond(f smartPlaylistField, cmp comparator, value any) squirrel.Sqlizer { + wrapNull, ok := bareNullInclusion(f.coalesceDefault, cmp, value) + if !ok { + return cmp.build(map[string]any{f.coalesced(): value}) + } + base := cmp.build(map[string]any{f.expr: value}) + if !wrapNull { + return base + } + // The default satisfies the predicate, so missing rows (NULL) must be included. All comparators + // (including <>) evaluate to false against NULL, so an explicit IS NULL restores them. + return squirrel.Or{base, squirrel.Eq{f.expr: nil}} +} + +// bareNullInclusion decides whether the index-friendly bare form is safe for this comparison and, +// if so, whether missing (NULL) rows must be re-included via `OR col IS NULL`. It returns ok=false +// when the bare form can't be proven equivalent to COALESCE(col, default) ? — i.e. the value +// isn't a scalar the comparator can order exactly (lists, bool ordering, unparseable values) — in +// which case the caller keeps the COALESCE form. When ok=true, wrapNull is true iff the default +// value itself satisfies the predicate (so missing rows would match and must be preserved). +func bareNullInclusion(defaultVal any, cmp comparator, value any) (wrapNull, ok bool) { + if b, isBool := defaultVal.(bool); isBool { + // Bool columns have an exact bare form only for equality; ordering operators fall back to + // COALESCE. Mapping both bools to 0/1 lets cmp.satisfy reuse the numeric eq/ne predicate. + if cmp.ordering { + return false, false + } + v, okV := criteria.ToBool(value) + if !okV { + return false, false + } + return cmp.satisfy(boolToFloat(b), boolToFloat(v)), true + } + d, okD := toFloat(defaultVal) + v, okV := toFloat(value) + if !okD || !okV { + // Non-scalar or unparseable value: no exact bare form, keep COALESCE. + return false, false + } + return cmp.satisfy(d, v), true +} + +func boolToFloat(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// toFloat coerces a scalar criteria value to float64. Criteria values come from JSON (float64, +// string) or are built in Go (int, float64), so only those types are handled; anything else — +// including a slice or an unparseable string — reports ok=false so the caller keeps the COALESCE +// form instead of an index-friendly bare comparison. +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + case string: + f, err := strconv.ParseFloat(n, 64) + return f, err == nil + default: + return 0, false + } +} + +// sqlLiteral renders an annotation field's COALESCE default (0 or false) as a SQL literal for ORDER +// BY. %v renders both bool and numeric defaults correctly (false/true, 0). +func sqlLiteral(v any) string { + return fmt.Sprintf("%v", v) +} + func fieldJoinType(name string) smartPlaylistJoinType { info, ok := criteria.LookupField(name) if !ok { @@ -496,7 +835,9 @@ func sortExpr(sortField string) (string, bool) { if !ok || field.expr == "" { return "", false } - mapped = field.expr + // Sorting keeps the COALESCE default so missing-annotation rows sort as that default + // (filtering drops COALESCE for index use, but ORDER BY has no index to preserve here). + mapped = field.coalesced() } if info.Numeric { mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go new file mode 100644 index 000000000..1dcf97871 --- /dev/null +++ b/persistence/criteria_sql_benchmark_test.go @@ -0,0 +1,291 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/pocketbase/dbx" +) + +const ( + benchNumArtists = 1_000 + benchNumTracks = 40_000 + benchNumPatterns = 500 + benchArtistsPerTrack = 3 +) + +// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance +// between the current implementation (merged join-table via criteria pipeline) and +// the old baseline (unmerged json_tree subqueries). +func BenchmarkSmartPlaylistRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "contains artist" patterns in an OR group + anyExprs := make(criteria.Any, benchNumPatterns) + for i := range benchNumPatterns { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) { + benchmarkUnmergedJSONTree(b, ctx) + }) +} + +// BenchmarkSmartPlaylistNegatedRole compares performance for smart playlists with many +// negated role conditions ANDed together (e.g. 500 "isNot artist" rules, issue #5511) +// between the current implementation (merged NOT EXISTS via criteria pipeline) and the +// old baseline (one separate NOT EXISTS subquery per pattern). +func BenchmarkSmartPlaylistNegatedRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl-neg.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "isNot artist" patterns in an AND group + allExprs := make(criteria.All, benchNumPatterns) + for i := range benchNumPatterns { + allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: allExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedNotExists", func(b *testing.B) { + benchmarkUnmergedNegatedJSONTree(b, ctx) + }) +} + +// benchmarkUnmergedNegatedJSONTree builds the old-style query with N separate negated +// json_tree EXISTS subqueries ANDed together (the pre-optimization baseline). +func benchmarkUnmergedNegatedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" AND ") + } + sb.WriteString("not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)") + args = append(args, fmt.Sprintf("Artist %04d", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + +// benchmarkCriteriaPipeline runs the criteria through the actual production code path: +// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. +func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { + b.Helper() + + cSQL := newSmartPlaylistCriteria(expr) + + // Build the full query matching buildSmartPlaylistQuery + addCriteria + sq := squirrel.Select("media_file.id").From("media_file") + cond, err := cSQL.Where() + if err != nil { + b.Fatal(err) + } + sq = sq.Where(cond) + if expr.Limit > 0 { + sq = sq.Limit(uint64(expr.Limit)) + } + if order := cSQL.OrderBy(); order != "" { + sq = sq.OrderBy(order) + } + + query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + b.Fatal(err) + } + + runBenchQuery(b, ctx, query, args) +} + +// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS +// subqueries (the pre-optimization baseline). +func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" OR ") + } + sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)") + args = append(args, fmt.Sprintf("%%Artist %04d%%", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + +func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) { + b.Helper() + sqlDB := db.Db() + b.ResetTimer() + for range b.N { + rows, err := sqlDB.QueryContext(ctx, query, args...) + if err != nil { + b.Fatal(err) + } + for rows.Next() { + var id string + _ = rows.Scan(&id) + } + rows.Close() + if err := rows.Err(); err != nil { + b.Fatal(err) + } + } +} + +func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) { + b.Helper() + + sqlDB := db.Db() + + ur := NewUserRepository(ctx, conn) + if err := ur.Put(&user); err != nil { + b.Fatal(err) + } + if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil { + b.Fatal(err) + } + + tx, err := sqlDB.Begin() + if err != nil { + b.Fatal(err) + } + + // Create artists + artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)") + if err != nil { + b.Fatal(err) + } + for i := range benchNumArtists { + if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil { + b.Fatal(err) + } + } + artistStmt.Close() + + // Ensure folder exists + folderID := "bench-folder" + if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil { + b.Fatal(err) + } + + // Create media files with participants JSON, cycling through artists + mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id, + duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + b.Fatal(err) + } + + // Populate media_file_artists join table + mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)") + if err != nil { + b.Fatal(err) + } + + for i := range benchNumTracks { + trackID := fmt.Sprintf("track-%05d", i) + + // Assign benchArtistsPerTrack artists to each track, cycling through the pool + artistEntries := make([]map[string]string, benchArtistsPerTrack) + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistEntries[a] = map[string]string{ + "id": fmt.Sprintf("artist-%04d", artistIdx), + "name": fmt.Sprintf("Artist %04d", artistIdx), + } + } + primaryArtistIdx := i % benchNumArtists + primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx) + primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx) + + participants := map[string][]map[string]string{"artist": artistEntries} + participantsJSON, _ := json.Marshal(participants) + + if _, err := mfStmt.Exec( + trackID, + fmt.Sprintf("music/%s.mp3", trackID), + fmt.Sprintf("Track %05d", i), + "Bench Album", + primaryArtistName, + primaryArtistID, + "bench-album", + 180, 2024, 5000000, "mp3", + "{}", + string(participantsJSON), + "[]", + 1, folderID, trackID, "mp3", + ); err != nil { + b.Fatal(err) + } + + // Insert all artist associations into the join table + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistID := fmt.Sprintf("artist-%04d", artistIdx) + if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil { + b.Fatal(err) + } + } + } + mfStmt.Close() + mfaStmt.Close() + + if err := tx.Commit(); err != nil { + b.Fatal(err) + } + + b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns", + benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns) +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index ae2695a4d..59bdc4452 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -1,6 +1,8 @@ package persistence import ( + "fmt" + "strings" "time" "github.com/navidrome/navidrome/model" @@ -12,7 +14,7 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate", "replaygain_album_gain"}) criteria.AddNumericTags([]string{"rate"}) }) @@ -28,16 +30,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { }, Entry("all group", criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, - "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + "(media_file.title LIKE ? AND annotation.rating > ?)", "%love%", 3), Entry("any group", criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is bool", criteria.Is{"loved": true}, "annotation.starred = ?", true), Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("gt", criteria.Gt{"playCount": 10}, "annotation.play_count > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 10), Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), @@ -47,8 +49,51 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), + // Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks + // with no annotation row behave as that default. To keep the annotation index usable, the + // COALESCE is dropped when the compared value cannot match the default (the missing-row + // case is then naturally excluded); otherwise an explicit `OR col IS NULL` preserves it. + Entry("is safe (value != default)", criteria.Is{"playCount": 3}, "annotation.play_count = ?", 3), + Entry("is unsafe (value == default)", criteria.Is{"playCount": 0}, + "(annotation.play_count = ? OR annotation.play_count IS NULL)", 0), + Entry("is bool false (value == default)", criteria.Is{"loved": false}, + "(annotation.starred = ? OR annotation.starred IS NULL)", false), + Entry("gt safe (value >= default)", criteria.Gt{"playCount": 0}, "annotation.play_count > ?", 0), + Entry("gt unsafe (value < default)", criteria.Gt{"playCount": -1}, + "(annotation.play_count > ? OR annotation.play_count IS NULL)", -1), + Entry("lt safe (value <= default)", criteria.Lt{"playCount": 0}, "annotation.play_count < ?", 0), + Entry("lt unsafe (value > default)", criteria.Lt{"playCount": 5}, + "(annotation.play_count < ? OR annotation.play_count IS NULL)", 5), + Entry("isNot annotation keeps null match", criteria.IsNot{"playCount": 3}, + "(annotation.play_count <> ? OR annotation.play_count IS NULL)", 3), + Entry("isNot annotation value == default", criteria.IsNot{"playCount": 0}, + "annotation.play_count <> ?", 0), + Entry("in range spanning default", criteria.InTheRange{"playCount": []int{-1, 5}}, + "((annotation.play_count >= ? OR annotation.play_count IS NULL) AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", -1, 5), + Entry("in range above default", criteria.InTheRange{"playCount": []int{1, 5}}, + "(annotation.play_count >= ? AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", 1, 5), + // A list value can't drive the index and a default-inclusive list has per-element NULL + // semantics, so the COALESCE form is kept to stay equivalent to the original. + Entry("is list keeps coalesce", criteria.Is{"playCount": []int{0, 3}}, + "COALESCE(annotation.play_count, 0) IN (?,?)", 0, 3), + // LIKE operators can't use the column index, so annotation fields keep the COALESCE form to + // match missing-annotation rows exactly as before (a NULL column never matches LIKE). + Entry("contains annotation keeps coalesce", criteria.Contains{"playCount": 0}, + "COALESCE(annotation.play_count, 0) LIKE ?", "%0%"), + Entry("starts with annotation keeps coalesce", criteria.StartsWith{"rating": 5}, + "COALESCE(annotation.rating, 0) LIKE ?", "5%"), + Entry("not contains annotation keeps coalesce", criteria.NotContains{"playCount": 0}, + "COALESCE(annotation.play_count, 0) NOT LIKE ?", "%0%"), + // Bool annotation fields only have a clean index-friendly form for equality; ordering + // comparators keep the COALESCE form so the missing-row default is honored exactly. + Entry("gt bool keeps coalesce", criteria.Gt{"loved": false}, + "COALESCE(annotation.starred, false) > ?", false), + // A list value on a bool field is non-scalar, so it keeps the COALESCE form too (same as the + // numeric list case) — otherwise a NULL column would diverge from the original. + Entry("is bool list keeps coalesce", criteria.Is{"loved": []any{true}}, + "COALESCE(annotation.starred, false) IN (?)", true), Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), @@ -56,9 +101,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), - Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), - Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"), // ReplayGain fields Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), @@ -70,9 +115,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isMissing — roles Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, - "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), // isPresent — tags Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), @@ -80,9 +125,73 @@ var _ = Describe("Smart playlist criteria SQL", func() { "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isPresent — roles Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, - "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), + // isMissing/isPresent — nullable column fields (ReplayGain) + Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true}, + "(media_file.rg_album_gain IS NULL)"), + Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false}, + "(media_file.rg_album_gain IS NOT NULL)"), + Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true}, + "(media_file.rg_track_peak IS NOT NULL)"), + Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false}, + "(media_file.rg_track_peak IS NULL)"), + // isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584) + Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true}, + "(media_file.rg_album_gain IS NULL)"), + Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true}, + "(media_file.rg_album_gain IS NOT NULL)"), + // isMissing/isPresent — string column fields (empty string means missing) + Entry("isMissing mbz_recording_id [true]", criteria.IsMissing{"mbz_recording_id": true}, + "(media_file.mbz_recording_id IS NULL OR media_file.mbz_recording_id = ?)", ""), + Entry("isMissing mbz_recording_id [false]", criteria.IsMissing{"mbz_recording_id": false}, + "(media_file.mbz_recording_id IS NOT NULL AND media_file.mbz_recording_id <> ?)", ""), + Entry("isPresent mbz_album_id [true]", criteria.IsPresent{"mbz_album_id": true}, + "(media_file.mbz_album_id IS NOT NULL AND media_file.mbz_album_id <> ?)", ""), + Entry("isPresent mbz_album_id [false]", criteria.IsPresent{"mbz_album_id": false}, + "(media_file.mbz_album_id IS NULL OR media_file.mbz_album_id = ?)", ""), + // lyrics: absence is encoded as '' or '[]' (empty serialized LyricList) + Entry("isMissing lyrics [true]", criteria.IsMissing{"lyrics": true}, + "(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"), + Entry("isPresent lyrics [true]", criteria.IsPresent{"lyrics": true}, + "(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"), + Entry("isMissing lyrics [false]", criteria.IsMissing{"lyrics": false}, + "(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"), + Entry("isPresent lyrics [false]", criteria.IsPresent{"lyrics": false}, + "(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"), + // isMissing/isPresent — nullable numeric columns (BPM, BitDepth) + Entry("isMissing bpm [true]", criteria.IsMissing{"bpm": true}, + "(media_file.bpm IS NULL)"), + Entry("isPresent bpm [true]", criteria.IsPresent{"bpm": true}, + "(media_file.bpm IS NOT NULL)"), + Entry("isMissing bitdepth [true]", criteria.IsMissing{"bitdepth": true}, + "(media_file.bit_depth IS NULL)"), + Entry("isPresent bitdepth [false]", criteria.IsPresent{"bitdepth": false}, + "(media_file.bit_depth IS NULL)"), + // isMissing/isPresent — more string column fields (empty string means missing) + Entry("isMissing album [true]", criteria.IsMissing{"album": true}, + "(media_file.album IS NULL OR media_file.album = ?)", ""), + Entry("isMissing comment [true]", criteria.IsMissing{"comment": true}, + "(media_file.comment IS NULL OR media_file.comment = ?)", ""), + Entry("isMissing catalognumber [true]", criteria.IsMissing{"catalognumber": true}, + "(media_file.catalog_num IS NULL OR media_file.catalog_num = ?)", ""), + Entry("isMissing discsubtitle [true]", criteria.IsMissing{"discsubtitle": true}, + "(media_file.disc_subtitle IS NULL OR media_file.disc_subtitle = ?)", ""), + Entry("isMissing albumcomment [true]", criteria.IsMissing{"albumcomment": true}, + "(media_file.mbz_album_comment IS NULL OR media_file.mbz_album_comment = ?)", ""), + Entry("isMissing sorttitle [true]", criteria.IsMissing{"sorttitle": true}, + "(media_file.sort_title IS NULL OR media_file.sort_title = ?)", ""), + Entry("isMissing sortalbum [true]", criteria.IsMissing{"sortalbum": true}, + "(media_file.sort_album_name IS NULL OR media_file.sort_album_name = ?)", ""), + Entry("isMissing sortartist [true]", criteria.IsMissing{"sortartist": true}, + "(media_file.sort_artist_name IS NULL OR media_file.sort_artist_name = ?)", ""), + Entry("isMissing sortalbumartist [true]", criteria.IsMissing{"sortalbumartist": true}, + "(media_file.sort_album_artist_name IS NULL OR media_file.sort_album_artist_name = ?)", ""), + Entry("isMissing explicitstatus [true]", criteria.IsMissing{"explicitstatus": true}, + "(media_file.explicit_status IS NULL OR media_file.explicit_status = ?)", ""), + Entry("isPresent comment [true]", criteria.IsPresent{"comment": true}, + "(media_file.comment IS NOT NULL AND media_file.comment <> ?)", ""), ) Describe("playlist permissions", func() { @@ -141,12 +250,12 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("returns an error when isMissing is used with a regular field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isPresent is used with a regular field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isMissing has a non-boolean value", func() { @@ -154,6 +263,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) + It("returns an error for a range over a tag/role field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where() + Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) + }) + + It("returns a clear error for a malformed range value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where() + Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) @@ -204,6 +323,237 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + Describe("JSON condition merging", func() { + It("merges multiple role conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"artist": "Pink Floyd"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%")) + }) + + It("does not merge role conditions from different roles", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("mfa.role = ?")) + // Two separate EXISTS since roles differ + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated role conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two separate "not exists" since they are negated + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large groups to avoid SQLite expression tree depth limit", func() { + // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups + anyExprs := make(criteria.Any, jsonCondBatchSize+1) + for i := range anyExprs { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + // First batch has jsonCondBatchSize patterns, second has 1 => total args: + // 2 roles + (jsonCondBatchSize + 1) patterns + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) + + It("merges role conditions while preserving non-role conditions", func() { + expr := criteria.Any{ + criteria.Contains{"title": "Love"}, + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.title LIKE ?")) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges multiple tag conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + criteria.Contains{"genre": "Punk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%")) + }) + + It("does not merge tag conditions from different tags", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"mood": "Happy"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated tag conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("merges role and tag conditions independently", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two merged EXISTS: one for roles, one for tags + Expect(strings.Count(sql, "exists")).To(Equal(2)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name + }) + + It("merges negated role conditions in an AND group into a single NOT EXISTS", func() { + expr := criteria.All{ + criteria.IsNot{"artist": "Beatles"}, + criteria.IsNot{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // A single NOT EXISTS with both names ORed inside (De Morgan) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("artist.name = ? OR artist.name = ?")) + Expect(args).To(HaveExactElements("artist", "Beatles", "Kraftwerk")) + }) + + It("merges negated notContains role conditions in an AND group", func() { + expr := criteria.All{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges negated tag conditions in an AND group into a single NOT EXISTS", func() { + expr := criteria.All{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%")) + }) + + It("does not merge a single negated condition with a positive one of the same role in AND", func() { + // AND of mixed polarity must not be collapsed: NOT EXISTS(a) AND EXISTS(b) + // is not equivalent to any single merged subquery. + expr := criteria.All{ + criteria.Contains{"artist": "Beatles"}, + criteria.IsNot{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // One positive EXISTS and one negated NOT EXISTS, kept separate + Expect(strings.Count(sql, "not exists")).To(Equal(1)) + Expect(strings.Count(sql, "exists")).To(Equal(2)) // "not exists" contains "exists" + }) + + It("does not merge negated conditions of different roles in AND", func() { + expr := criteria.All{ + criteria.IsNot{"artist": "Beatles"}, + criteria.IsNot{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large negated AND groups to avoid SQLite expression tree depth limit", func() { + allExprs := make(criteria.All, jsonCondBatchSize+1) + for i := range allExprs { + allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two NOT EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) + }) + Describe("joins", func() { It("excludes sort-only joins from expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index f42292f02..1ff2139e6 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -70,11 +70,13 @@ var ( func buildTestFS() { abbeyRoad := template(_t{ - "albumartist": "The Beatles", - "artist": "The Beatles", - "album": "Abbey Road", - "year": 1969, - "genre": "Rock;Blues", + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + "replaygain_album_gain": "-6.5 dB", + "replaygain_album_peak": "0.98", }) ledZepIV := template(_t{ "albumartist": "Led Zeppelin", @@ -116,12 +118,16 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks", + "replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks", + "replaygain_track_gain": "-6.0 dB", "replaygain_track_peak": "0.92"})), + // Stairway To Heaven has track gain but no album gain, to distinguish the two fields "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", - "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, + "replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})), "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index a844dc982..4966d3b8a 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -371,4 +371,61 @@ var _ = Describe("Smart Playlists", func() { Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) }) }) + + // ReplayGain values are stored in nullable media_file columns (not in the tags JSON), so + // isMissing/isPresent translate to IS [NOT] NULL checks on those columns (issue #5584). + Describe("isMissing/isPresent on ReplayGain fields", func() { + It("isMissing finds tracks without album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with album gain", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgalbumgain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with album peak", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgalbumpeak":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isMissing distinguishes track gain from album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgtrackgain":true}}]}`) + Expect(results).To(ConsistOf("Black Dog", "So What", "Bohemian Rhapsody", + "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent finds tracks with track gain", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgtrackgain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + }) + + It("resolves the replaygain_album_gain alias to the rgalbumgain column", func() { + results := evaluateRule(`{"all":[{"isMissing":{"replaygain_album_gain":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("resolves the replaygain_track_gain alias to the rgtrackgain column", func() { + results := evaluateRule(`{"all":[{"isPresent":{"replaygain_track_gain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + }) + + It("supports numeric comparisons through the replaygain_* alias", func() { + results := evaluateRule(`{"all":[{"gt":{"replaygain_track_gain":-7.5}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("combines isMissing on ReplayGain with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index f7bb6a4fe..8fb7f0296 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -7,6 +7,7 @@ import ( "iter" "maps" "os" + "path" "path/filepath" "slices" "strings" @@ -188,6 +189,33 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol return m, nil } +// HasAudioOutsideFolders reports whether any folder in parent's subtree +// (including parent itself) contains audio files and is not one of the given +// folder IDs. LIKE wildcards in the parent path are escaped, so it is always +// matched as a literal prefix. +func (r folderRepository) HasAudioOutsideFolders(parent model.Folder, excludeFolderIDs []string) (bool, error) { + if parent.NumAudioFiles > 0 { + return true, nil + } + parentPath := strings.TrimPrefix(path.Join(parent.Path, parent.Name), "/") + return r.exists(And{ + Eq{"library_id": parent.LibraryID, "missing": false}, + Gt{"num_audio_files": 0}, + NotEq{"id": excludeFolderIDs}, + Or{ + // Direct children have path = parentPath; deeper descendants match the prefix + Eq{"path": parentPath}, + Expr(`path LIKE ? ESCAPE '\'`, escapeLikePrefix(parentPath)+"/%"), + }, + }) +} + +// escapeLikePrefix escapes SQL LIKE wildcards so a string can be used as a +// literal prefix in a LIKE pattern (with ESCAPE '\'). +func escapeLikePrefix(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + func (r folderRepository) Put(f *model.Folder) error { dbf := dbFolder{Folder: f} _, err := r.put(dbf.ID, &dbf) @@ -222,6 +250,18 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) return wrapFolderCursor(cursor), nil } +func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { + query := r.selectFolder().Where(And{ + Eq{"missing": false}, + Gt{"num_playlists": 0}, + }) + cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) + if err != nil { + return nil, err + } + return wrapFolderCursor(cursor), nil +} + func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return func(yield func(model.Folder, error) bool) { for f, err := range cursor { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index ebc08fd04..a8945dfee 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -217,6 +217,67 @@ var _ = Describe("FolderRepository", func() { }) }) + Describe("HasAudioOutsideFolders", func() { + var albumRoot, disc1, disc2 *model.Folder + + // TestHasAudio/Album/ + // ├── CD1/ (audio, belongs to the album) + // └── CD2/ (audio, belongs to the album) + BeforeEach(func() { + albumRoot = model.NewFolder(testLib, "TestHasAudio/Album") + disc1 = model.NewFolder(testLib, "TestHasAudio/Album/CD1") + disc1.NumAudioFiles = 5 + disc2 = model.NewFolder(testLib, "TestHasAudio/Album/CD2") + disc2.NumAudioFiles = 5 + for _, f := range []*model.Folder{albumRoot, disc1, disc2} { + Expect(repo.Put(f)).To(Succeed()) + } + }) + + It("returns false when all audio under the parent belongs to the given folders", func() { + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + }) + + It("returns true when another folder under the parent has audio", func() { + bonus := model.NewFolder(testLib, "TestHasAudio/Album/Bonus") + bonus.NumAudioFiles = 1 + Expect(repo.Put(bonus)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) + }) + + It("returns true when the parent itself contains audio files", func() { + albumRoot.NumAudioFiles = 2 + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) + }) + + It("ignores audio outside the parent's subtree", func() { + other := model.NewFolder(testLib, "TestHasAudio/Other Album") + other.NumAudioFiles = 10 + Expect(repo.Put(other)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + }) + + It("ignores missing folders", func() { + gone := model.NewFolder(testLib, "TestHasAudio/Album/Gone") + gone.NumAudioFiles = 3 + gone.Missing = true + Expect(repo.Put(gone)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) + }) + + It("does not treat LIKE wildcards in the parent path as patterns", func() { + // "TestHas_udio" would LIKE-match "TestHasAudio" if "_" were not escaped + wildcardRoot := model.NewFolder(testLib, "TestHas_udio/Album") + Expect(repo.Put(wildcardRoot)).To(Succeed()) + + Expect(repo.HasAudioOutsideFolders(*wildcardRoot, []string{"none"})).To(BeFalse()) + }) + }) + Describe("wrapFolderCursor", func() { It("does not panic when the cursor yields a dbFolder with nil Folder", func() { // Simulate what queryWithStableResults does on the rows.Err() path: @@ -256,4 +317,36 @@ var _ = Describe("FolderRepository", func() { Expect(folders[0].ID).To(Equal("f1")) }) }) + + Describe("GetAllWithPlaylists", func() { + It("returns all non-missing folders with playlists, ignoring the scan-timestamp gate", func() { + withPls := model.NewFolder(testLib, "TestAllPls/WithPls") + withPls.NumPlaylists = 2 + noPls := model.NewFolder(testLib, "TestAllPls/NoPls") + noPls.NumPlaylists = 0 + missingWithPls := model.NewFolder(testLib, "TestAllPls/Missing") + missingWithPls.NumPlaylists = 1 + missingWithPls.Missing = true + + Expect(repo.Put(withPls)).To(Succeed()) + Expect(repo.Put(noPls)).To(Succeed()) + Expect(repo.Put(missingWithPls)).To(Succeed()) + + // Force the folder's updated_at to the past so GetTouchedWithPlaylists + // (which gates on updated_at > last_scan_at) would NOT return it. + _, err := conn.NewQuery("UPDATE folder SET updated_at = {:t} WHERE id = {:id}"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00", "id": withPls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + var ids []string + cursor, err := repo.GetAllWithPlaylists() + Expect(err).ToNot(HaveOccurred()) + for f, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + ids = append(ids, f.ID) + } + + Expect(ids).To(ConsistOf(withPls.ID)) // only the non-missing folder with playlists + }) + }) }) diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 53f324bf4..22443284f 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -14,9 +14,8 @@ type genreRepository struct { } func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { - genreFilter := model.TagGenre return &genreRepository{ - baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), + baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)), } } diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 1d8e6f35e..5a0142423 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -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 } @@ -261,6 +252,11 @@ func (r *libraryRepository) Delete(id int) error { return err } + // The cascade above can drop an artist's last library_artist row; reconcile any such orphans. + if err := NewArtistRepository(r.ctx, r.db).(*artistRepository).markOrphansMissing(); err != nil { + return fmt.Errorf("marking orphaned artists missing after deleting library %d: %w", id, err) + } + // Clear cache entry for this library only if DB operation was successful libLock.Lock() defer libLock.Unlock() diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index de7161643..1743df209 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -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/request" @@ -206,4 +207,49 @@ var _ = Describe("LibraryRepository", func() { }) }) }) + + Describe("Delete", func() { + var adminRepo model.LibraryRepository + var artistRepo model.ArtistRepository + + artistMissing := func(id string) bool { + var missing bool + err := conn.NewQuery("SELECT missing FROM artist WHERE id = {:id}"). + Bind(dbx.Params{"id": id}).Row(&missing) + Expect(err).ToNot(HaveOccurred()) + return missing + } + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) + adminRepo = NewLibraryRepository(adminCtx, conn) + artistRepo = NewArtistRepository(adminCtx, conn) + }) + + It("marks artists orphaned by the delete as missing", func() { + lib := model.Library{Name: "Doomed Library", Path: "/doomed"} + Expect(adminRepo.Put(&lib)).To(Succeed()) + + orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"} + sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"} + Expect(artistRepo.Put(&orphanArtist)).To(Succeed()) + Expect(artistRepo.Put(&sharedArtist)).To(Succeed()) + Expect(adminRepo.AddArtist(lib.ID, orphanArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(lib.ID, sharedArtist.ID)).To(Succeed()) + Expect(adminRepo.AddArtist(1, sharedArtist.ID)).To(Succeed()) + DeferCleanup(func() { + if raw, ok := artistRepo.(*artistRepository); ok { + _, _ = raw.executeSQL(squirrel.Delete("artist"). + Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}})) + } + }) + + Expect(artistMissing(orphanArtist.ID)).To(BeFalse()) + + Expect(adminRepo.Delete(lib.ID)).To(Succeed()) + + Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing") + Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible") + }) + }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 264778ea0..b4979ca77 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -62,7 +63,7 @@ func (m *dbMediaFile) PostMapArgs(args map[string]any) error { fullText = append(fullText, participantNames...) args["full_text"] = formatFullText(fullText...) args["search_participants"] = strings.Join(participantNames, " ") - args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) + args["search_normalized"] = str.NormalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) args["tags"] = marshalTags(m.MediaFile.Tags) args["participants"] = marshalParticipants(m.MediaFile.Participants) return nil @@ -90,6 +91,16 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile "recently_added": mediaFileRecentlyAddedSort(), "starred_at": "starred, starred_at", "rated_at": "rating, rated_at", + "year": "year", + "genre": "genre", + "duration": "duration", + "channels": "channels", + "bpm": "bpm", + "path": "path", + "comment": "comment", + "play_count": "play_count", + "play_date": "play_date", + "rating": "rating", }) return r } @@ -104,6 +115,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "missing": booleanFilter, "artists_id": artistFilter, "library_id": libraryIdFilter, + "path": startsWithFilter("media_file.path"), } // Add all album tags as filters for tag := range model.TagMappings() { @@ -116,15 +128,18 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { func mediaFileRecentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "media_file.updated_at" + return "media_file.updated_at, media_file.id" } - return "media_file.created_at" + return "media_file.created_at, media_file.id" } func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() - query = r.withAnnotation(query, "media_file.id") query = r.applyLibraryFilter(query) + // The annotation join is expensive with count(distinct) and pointless unless a filter uses it. + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "media_file.id") + } return r.count(query, options...) } @@ -206,6 +221,40 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media return res.toModels(), nil } +// GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the +// wide media_file row: pick random rowids first, then hydrate only those. +func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { + var opt model.QueryOptions + if len(options) > 0 { + opt = options[0] + } + + rowidQuery := Select("media_file.rowid").From(r.tableName) + rowidQuery = r.applyFilters(rowidQuery, model.QueryOptions{Filters: opt.Filters}) + rowidQuery = r.applyLibraryFilter(rowidQuery) + rowidQuery = rowidQuery.OrderBy("random()") + if opt.Max > 0 { + rowidQuery = rowidQuery.Limit(uint64(opt.Max)) + } + + var rowids []int64 + if err := r.queryAllSlice(rowidQuery, &rowids); err != nil { + return nil, err + } + if len(rowids) == 0 { + return model.MediaFiles{}, nil + } + + // Re-shuffle in Phase 2: `WHERE rowid IN (...)` returns rows in ascending rowid order, not + // the random order from Phase 1. Sorting only the (<=Max) hydrated rows is negligible. + sq := r.selectMediaFile().Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") + var res dbMediaFiles + if err := r.queryAll(sq, &res); err != nil { + return nil, err + } + return res.toModels(), nil +} + func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { placeholders := make([]string, len(values)) args := make([]any, len(values)) @@ -271,7 +320,7 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err return model.MediaFiles{}, nil } - sel := r.newSelect().Columns("*").Where(query) + sel := r.applyLibraryFilter(r.newSelect().Columns("*").Where(query)) var res dbMediaFiles if err := r.queryAll(sel, &res); err != nil { return nil, err diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 464d88288..80d440c41 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "time" "github.com/Masterminds/squirrel" @@ -44,6 +45,37 @@ var _ = Describe("MediaRepository", func() { Expect(mr.CountAll()).To(Equal(int64(13))) }) + Describe("CountAll annotation-join gating", func() { + var adminRepo model.MediaFileRepository + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) + adminRepo = NewMediaFileRepository(adminCtx, GetDBXBuilder()) + }) + + It("counts starred songs when an annotation filter is present", func() { + // Come Together (id 1002) is starred for the admin user in the seed data + count, err := adminRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + }) + + It("counts with starred=false without a 'no such column' error (join kept)", func() { + count, err := adminRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "false"), + }) + Expect(err).ToNot(HaveOccurred()) + // All songs except the one starred one + Expect(count).To(Equal(int64(12))) + }) + + It("counts unfiltered with the join dropped", func() { + Expect(adminRepo.CountAll()).To(Equal(int64(13))) + }) + }) + Describe("CountBySuffix", func() { var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile @@ -106,6 +138,102 @@ var _ = Describe("MediaRepository", func() { } }) + Describe("GetRandom", func() { + It("returns the requested number of distinct, fully-hydrated media files", func() { + results, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(5)) + + // Each returned row must match its GetAll counterpart exactly — proves Phase 2 + // hydrates full rows (not bare rowids) — and ids must be distinct. + byID := map[string]model.MediaFile{} + all, err := mr.GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range all { + byID[mf.ID] = mf + } + seen := map[string]bool{} + for _, mf := range results { + expected, ok := byID[mf.ID] + Expect(ok).To(BeTrue(), "returned id must be a real media file") + Expect(mf.Title).To(Equal(expected.Title), "row must be fully hydrated") + Expect(seen[mf.ID]).To(BeFalse(), "no duplicate rows") + seen[mf.ID] = true + } + }) + + It("returns all matching files when Max exceeds the total", func() { + results, err := mr.GetRandom(model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(13)) + }) + + It("honors filters", func() { + results, err := mr.GetRandom(model.QueryOptions{ + Max: 10, + Filters: squirrel.Eq{"media_file.title": "Antenna"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + for _, mf := range results { + Expect(mf.Title).To(Equal("Antenna")) + } + }) + + It("returns varying results across calls", func() { + // Retry a few times: two random draws of 5 from 13 rows differ with near-certainty. + first, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + firstIDs := func() []string { + ids := make([]string, len(first)) + for i, mf := range first { + ids[i] = mf.ID + } + return ids + }() + differed := false + for range 10 { + next, err := mr.GetRandom(model.QueryOptions{Max: 5}) + Expect(err).ToNot(HaveOccurred()) + nextIDs := make([]string, len(next)) + for i, mf := range next { + nextIDs[i] = mf.ID + } + if !reflect.DeepEqual(firstIDs, nextIDs) { + differed = true + break + } + } + Expect(differed).To(BeTrue(), "GetRandom should not return an identical set every call") + }) + + It("randomizes order even when Max exceeds the total", func() { + // Same set of rows every time (all 13), but the order must still be shuffled — + // guards against Phase 2's `rowid IN (...)` returning rows in rowid order. + first, err := mr.GetRandom(model.QueryOptions{Max: 100}) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(HaveLen(13)) + firstIDs := make([]string, len(first)) + for i, mf := range first { + firstIDs[i] = mf.ID + } + differed := false + for range 10 { + next, err := mr.GetRandom(model.QueryOptions{Max: 100}) + Expect(err).ToNot(HaveOccurred()) + nextIDs := make([]string, len(next)) + for i, mf := range next { + nextIDs[i] = mf.ID + } + if !reflect.DeepEqual(firstIDs, nextIDs) { + differed = true + break + } + } + Expect(differed).To(BeTrue(), "order must vary even when returning all rows") + }) + }) + Describe("Put CreatedAt behavior (#5050)", func() { It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() { before := time.Now().Add(-time.Second) @@ -479,6 +607,34 @@ var _ = Describe("MediaRepository", func() { }) }) + It("breaks ties deterministically when files share the same created_at", func() { + conf.Server.RecentlyAddedByModTime = false + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + repo := NewMediaFileRepository(ctx, GetDBXBuilder()) + + ids := []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID} + sameTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) + _, err := GetDBXBuilder().Update("media_file", + dbx.Params{"created_at": sameTime}, + dbx.In("id", ids[0], ids[1], ids[2])).Execute() + Expect(err).ToNot(HaveOccurred()) + + order := func() []string { + res, err := repo.GetAll(model.QueryOptions{ + Sort: "recently_added", Order: "desc", + Filters: squirrel.Eq{"media_file.id": ids}}) + Expect(err).ToNot(HaveOccurred()) + out := make([]string, len(res)) + for i, mf := range res { + out[i] = mf.ID + } + return out + } + // Stable across repeated queries (no query-plan-dependent reordering). + Expect(order()).To(Equal(order())) + }) + }) }) @@ -524,6 +680,34 @@ var _ = Describe("MediaRepository", func() { } }) }) + + Describe("path", func() { + It("matches files whose path starts with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "test/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + + var found bool + for _, f := range files { + Expect(f.Path).To(HavePrefix("test/")) + if f.ID == mfWithoutAnnotation.ID { + found = true + } + } + Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included") + }) + + It("excludes files whose path does not start with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "no-such-prefix/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + Expect(files).To(BeEmpty()) + }) + }) }) Describe("Search", func() { @@ -624,6 +808,49 @@ var _ = Describe("MediaRepository", func() { _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) }) }) + + Context("empty query (natural order pagination)", func() { + It("returns all non-missing files in natural order", func() { + results, err := mr.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + for _, result := range results { + Expect(result.Missing).To(BeFalse()) + } + }) + + It(`treats quoted empty query ("") the same as empty`, func() { + all, err := mr.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + quoted, err := mr.Search(`""`, model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(quoted).To(HaveLen(len(all))) + }) + + It("paginates without overlaps or gaps", func() { + all, err := mr.Search("", model.QueryOptions{Max: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(all)).To(BeNumerically(">", 3)) + + var paged model.MediaFiles + pageSize := 3 + for offset := 0; offset < len(all); offset += pageSize { + page, err := mr.Search("", model.QueryOptions{Max: pageSize, Offset: offset}) + Expect(err).ToNot(HaveOccurred()) + paged = append(paged, page...) + } + Expect(paged).To(HaveLen(len(all))) + for i := range all { + Expect(paged[i].ID).To(Equal(all[i].ID), fmt.Sprintf("row %d differs", i)) + } + }) + + It("returns empty page when offset is beyond the total", func() { + results, err := mr.Search("", model.QueryOptions{Max: 10, Offset: 100000}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) }) Describe("FindByPaths", func() { @@ -712,6 +939,58 @@ var _ = Describe("MediaRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) + + Context("when the user has restricted library access", func() { + var otherLib model.Library + var restrictedUser model.User + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), adminUser) + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + + // A second library the restricted user has no access to + otherLib = model.Library{ID: 0, Name: "Other Library", Path: "/other/lib"} + Expect(lr.Put(&otherLib)).To(Succeed()) + + // A track that lives only in the other library (created as admin) + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.Put(&model.MediaFile{ + ID: "otherlib-track", LibraryID: otherLib.ID, + Path: "hidden/test.mp3", Title: "Hidden", + })).To(Succeed()) + + // Non-admin user with access to library 1 ONLY + restrictedUser = createUserWithLibraries("restricted-finder", []int{1}) + ur := NewUserRepository(adminCtx, GetDBXBuilder()) + Expect(ur.Put(&restrictedUser)).To(Succeed()) + Expect(ur.SetUserLibraries(restrictedUser.ID, []int{1})).To(Succeed()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), adminUser) + _ = NewMediaFileRepository(adminCtx, GetDBXBuilder()).Delete("otherlib-track") + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, GetDBXBuilder()).Delete(restrictedUser.ID) + }) + + It("does not resolve paths in libraries the user cannot access", func() { + userMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), restrictedUser), GetDBXBuilder()) + qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) + results, err := userMr.FindByPaths([]string{qualified}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty(), "a track outside the user's libraries must not be resolvable") + }) + + It("still resolves the path for an admin", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) + results, err := adminMr.FindByPaths([]string{qualified}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("otherlib-track")) + }) + }) }) Describe("wrapMediaFileCursor", func() { @@ -753,4 +1032,49 @@ var _ = Describe("MediaRepository", func() { Expect(mediafiles[0].ID).To(Equal("mf1")) }) }) + + Describe("BPM and BitDepth nullable round-trip", func() { + It("stores nil BPM and BitDepth as NULL and retrieves them as nil", func() { + newID := id.NewRandom() + mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-nil.mp3"} + Expect(mr.Put(&mf)).To(Succeed()) + + retrieved, err := mr.Get(newID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.BPM).To(BeNil()) + Expect(retrieved.BitDepth).To(BeNil()) + + // Also verify via raw SQL that the columns are truly NULL (not 0) + db := GetDBXBuilder() + var row struct { + BPM *int `db:"bpm"` + BitDepth *int `db:"bit_depth"` + } + err = db.NewQuery("SELECT bpm, bit_depth FROM media_file WHERE id={:id}"). + Bind(dbx.Params{"id": newID}). + One(&row) + Expect(err).ToNot(HaveOccurred()) + Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database") + Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database") + + _ = mr.Delete(newID) + }) + + It("stores non-nil BPM and BitDepth and retrieves correct values", func() { + newID := id.NewRandom() + bpm := 120 + bitDepth := 24 + mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth} + Expect(mr.Put(&mf)).To(Succeed()) + + retrieved, err := mr.Get(newID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.BPM).ToNot(BeNil()) + Expect(*retrieved.BPM).To(Equal(120)) + Expect(retrieved.BitDepth).ToNot(BeNil()) + Expect(*retrieved.BitDepth).To(Equal(24)) + + _ = mr.Delete(newID) + }) + }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 83211bdd5..93f0e3e71 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -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() }), diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ebc247d77..4f2fd7fe2 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" @@ -13,7 +14,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -103,7 +103,7 @@ var ( songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Path: p("kraft/radio/antenna.mp3"), - RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), + RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ ID: "1005", @@ -158,12 +158,17 @@ 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) } -// Initialize test DB -// TODO Load this data setup from file(s) var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) @@ -187,8 +192,7 @@ var _ = BeforeSuite(func() { alr := NewAlbumRepository(ctx, conn).(*albumRepository) for i := range testAlbums { - a := testAlbums[i] - err := alr.Put(&a) + err := alr.Put(new(testAlbums[i])) if err != nil { panic(err) } @@ -196,8 +200,7 @@ var _ = BeforeSuite(func() { arr := NewArtistRepository(ctx, conn) for i := range testArtists { - a := testArtists[i] - err := arr.Put(&a) + err := arr.Put(new(testArtists[i])) if err != nil { panic(err) } @@ -243,8 +246,7 @@ var _ = BeforeSuite(func() { rar := NewRadioRepository(ctx, conn) for i := range testRadios { - r := testRadios[i] - err := rar.Put(&r) + err := rar.Put(new(testRadios[i])) if err != nil { panic(err) } @@ -310,6 +312,18 @@ 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 { diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 6c8339378..353b0444f 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -62,18 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu return s.Where(r.addRestriction()) } -func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { - s := And{} - if len(sql) > 0 { - s = append(s, sql[0]) - } - u := loggedUser(r.ctx) - if u.IsAdmin { - return s - } - return append(s, Eq{"user_id": u.ID}) -} - func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { sel := r.newSelect(options...). Columns( @@ -125,6 +113,10 @@ func (r *playerRepository) NewInstance() any { return &model.Player{} } +// isPermitted authorizes creating a new record, based on the owner declared in the request body. +// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a +// player they own. Updates must not use this (the body owner is attacker-controlled); they go +// through updateOwned, which authorizes against the persisted user_id in the WHERE clause. func (r *playerRepository) isPermitted(p *model.Player) bool { u := loggedUser(r.ctx) return u.IsAdmin || p.UserId == u.ID @@ -145,23 +137,11 @@ func (r *playerRepository) Save(entity any) (string, error) { func (r *playerRepository) Update(id string, entity any, cols ...string) error { t := entity.(*model.Player) t.ID = id - if !r.isPermitted(t) { - return rest.ErrPermissionDenied - } - _, err := r.put(id, t, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.updateOwned(id, t, cols...) } func (r *playerRepository) Delete(id string) error { - filter := r.addRestriction(And{Eq{"player.id": id}}) - err := r.delete(filter) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } var _ model.PlayerRepository = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f6c669493..b7085a1fb 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() { }) Describe("Delete", func() { - DescribeTable("item type", func(player model.Player) { - err := repo.Delete(player.ID) + It("deletes a player owned by the current user", func() { + err := repo.Delete(userPlayer.ID) Expect(err).To(BeNil()) - isReal := player.UserId != "" - canDelete := admin || player.UserId == userPlayer.UserId - count, err := repo.Count() Expect(err).To(BeNil()) + Expect(count).To(Equal(baseCount - 1)) - if isReal && canDelete { - Expect(count).To(Equal(baseCount - 1)) - } else { - Expect(count).To(Equal(baseCount)) - } + _, err = repo.Get(userPlayer.ID) + Expect(err).To(Equal(model.ErrNotFound)) + }) - item, err := repo.Get(player.ID) - if !isReal || canDelete { + It("does not delete another user's player when not admin", func() { + err := repo.Delete(otherPlayer.ID) + + if admin { + // Admins may delete any player. + Expect(err).To(BeNil()) + Expect(repo.Count()).To(Equal(baseCount - 1)) + _, err = repo.Get(otherPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) } else { - Expect(*item).To(Equal(player)) + // The ownership-restricted delete matches no owned row, so it reports + // permission-denied and leaves the other user's player untouched. + Expect(err).To(Equal(rest.ErrPermissionDenied)) + Expect(repo.Count()).To(Equal(baseCount)) + item, err := repo.Get(otherPlayer.ID) + Expect(err).To(BeNil()) + Expect(*item).To(Equal(otherPlayer)) } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) + }) + + It("returns not-found for a nonexistent player", func() { + err := repo.Delete("i don't exist") + Expect(err).To(Equal(rest.ErrNotFound)) + Expect(repo.Count()).To(Equal(baseCount)) + }) }) Describe("Read", func() { @@ -215,9 +225,12 @@ var _ = Describe("PlayerRepository", func() { clone.MaxBitRate = 10000 err := repo.Update(clone.ID, &clone, "ip") - if clone.UserId == "" { + if player.UserId == "" { Expect(err).To(HaveOccurred()) } else if !admin && player.Username == adminPlayer1.Username { + // A non-admin cannot target another user's player: the ownership-restricted + // update matches no owned row, so it reports permission-denied rather than + // touching it. Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { @@ -244,4 +257,86 @@ var _ = Describe("PlayerRepository", func() { Entry("admin context", true, players, adminPlayer1, regularPlayer), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) + + Describe("Ownership enforcement (cross-tenant write protection)", func() { + var regularRepo *playerRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + regularRepo = NewPlayerRepository(ctx, database).(*playerRepository) + }) + + It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { + // Attacker (regularUser) targets the victim's (adminUser) player by URL id, + // but sets userId in the body to their own id to try to pass the permission check. + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, // attacker's own id, spoofed in the body + MaxBitRate: 1, + } + + // The ownership-restricted update matches no row owned by the attacker, so the write + // targets nothing and reports permission-denied rather than overwriting the victim's row. + err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The victim's player must remain untouched. + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + }) + + It("does not let a regular user reassign their own player to another user", func() { + // Owner updates their own player but tries to give it away to the admin. The update + // succeeds for the other fields, but user_id is never written, so ownership stays put. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "given-away" + + err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // Ownership must not have changed. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("does not let an admin reassign a player to another user", func() { + // Even an admin cannot change a player's owner via update. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "admin-renamed" + + err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // The name change applies, but ownership must not have moved. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("admin-renamed")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("lets the owner update their own player", func() { + update := regularPlayer + update.Name = "renamed-by-owner" + + err := regularRepo.Update(regularPlayer.ID, &update, "name") + Expect(err).To(BeNil()) + + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("renamed-by-owner")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("returns not found when updating a nonexistent player", func() { + ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} + err := regularRepo.Update("does-not-exist", &ghost, "name") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 4152505d2..fe1f50689 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -203,8 +203,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 { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index cfabd0983..c5b16b88f 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" + "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() { @@ -71,6 +74,98 @@ 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("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"} diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index c952b42b1..ba69ec746 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -89,8 +89,7 @@ func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue err := r.queryOne(sel, &res) - q := r.toModel(&res) - return &q, err + return new(r.toModel(&res)), err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 88a31ac49..05628ca41 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -11,10 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var ( - NewId string = "123-456-789" -) - var _ = Describe("RadioRepository", func() { var repo model.RadioRepository @@ -34,8 +30,7 @@ var _ = Describe("RadioRepository", func() { } for i := range testRadios { - r := testRadios[i] - err := repo.Put(&r) + err := repo.Put(new(testRadios[i])) if err != nil { panic(err) } @@ -140,7 +135,7 @@ var _ = Describe("RadioRepository", func() { It("returns an existing item", func() { res, err := repo.Get(radioWithHomePage.ID) - Expect(err).To((BeNil())) + Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index 3cfb836bf..cf54c664a 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -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()) } diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index edf59ce49..3aa71070e 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -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") diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..7cc60ae23 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -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) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index d43848d03..e9103b127 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -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)) + }) + }) + }) }) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 415109640..0013e782b 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -30,47 +30,18 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito return r } -// TODO: Ownership checks should be moved to the service layer (core/share.go) -func (r *shareRepository) checkOwnership(id string) error { - usr := loggedUser(r.ctx) - if usr.IsAdmin || usr.ID == invalidUserId { - return nil - } - sel := r.newSelect().Columns("user_id").Where(Eq{"id": id}) - var share struct { - UserID string `db:"user_id"` - } - err := r.queryOne(sel, &share) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err - } - if share.UserID != usr.ID { - return rest.ErrPermissionDenied - } - return nil -} - func (r *shareRepository) Delete(id string) error { - if err := r.checkOwnership(id); err != nil { - return err - } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...).Join("user u on u.id = share.user_id"). - Columns("share.*", "user_name as username") + Columns("share.*", "user_name as username"). + Where(r.addRestriction()) } func (r *shareRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"id": id}) + return r.exists(r.addRestriction(And{Eq{"id": id}})) } func (r *shareRepository) Get(id string) (*model.Share, error) { @@ -129,16 +100,28 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - // Create a context with a fake admin user, to be able to access all playlists - ctx := request.WithUser(r.ctx, model.User{IsAdmin: true}) + // Load tracks as the share owner so their library access is applied. + owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + if err != nil { + return fmt.Errorf("loading share owner %q: %w", share.UserID, err) + } + if owner == nil { + return fmt.Errorf("share owner %q not found", share.UserID) + } + ctx := request.WithUser(r.ctx, *owner) plsRepo := NewPlaylistRepository(ctx, r.db) - tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + // Tracks returns nil when the playlist is no longer visible to the owner + // (e.g. it was made private after the share was created); leave the share + // with no tracks rather than exposing it. + trackRepo := plsRepo.Tracks(ids[0], true) + if trackRepo == nil { + return nil + } + tracks, err := trackRepo.GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) if err != nil { return err } - if len(tracks) >= 0 { - share.Tracks = tracks.MediaFiles() - } + share.Tracks = tracks.MediaFiles() return nil case "media_file": mfRepo := NewMediaFileRepository(r.ctx, r.db) @@ -166,17 +149,12 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { func (r *shareRepository) Update(id string, entity any, cols ...string) error { s := entity.(*model.Share) - if err := r.checkOwnership(id); err != nil { - return err - } s.ID = id s.UpdatedAt = time.Now() - cols = append(cols, "updated_at") - _, err := r.put(id, s, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound + if len(cols) > 0 { + cols = append(cols, "updated_at") } - return err + return r.updateOwned(id, s, cols...) } func (r *shareRepository) Save(entity any) (string, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 6988f323f..3ae456031 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -20,7 +21,7 @@ var _ = Describe("ShareRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo = NewShareRepository(ctx, GetDBXBuilder()) // Insert the admin user into the database (required for foreign key constraint) @@ -38,7 +39,7 @@ var _ = Describe("ShareRepository", func() { Context("Repository creation and basic operations", func() { It("should create repository successfully with no user context", func() { // Create repository with no user context (headless) - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) Expect(headlessRepo).ToNot(BeNil()) }) @@ -60,7 +61,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should see all shares - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) shares, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) @@ -92,7 +93,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the share - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) share, err := headlessRepo.Get(shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) @@ -132,6 +133,101 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Playlist share library scoping", func() { + var otherLib model.Library + var owner model.User + var plsID string + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + + // A second library the owner has no access to, plus a track in it + lr := NewLibraryRepository(adminCtx, GetDBXBuilder()) + otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"} + Expect(lr.Put(&otherLib)).To(Succeed()) + mr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(mr.Put(&model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed()) + + // Non-admin owner with access to library 1 only + owner = createUserWithLibraries("share-owner", []int{1}) + ur := NewUserRepository(adminCtx, GetDBXBuilder()) + Expect(ur.Put(&owner)).To(Succeed()) + Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + + // Owner-owned playlist containing tracks from both libraries + plsID = "share-scope-pls" + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), owner) + pr := NewPlaylistRepository(ownerCtx, GetDBXBuilder()) + pls := &model.Playlist{ID: plsID, Name: "Scope Test", OwnerID: owner.ID} + pls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}, {ID: "share-other"}}) + Expect(pr.Put(pls)).To(Succeed()) + + // Share row owned by the non-admin owner + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "share-scope", "user": owner.ID, "desc": "Scope test share", + "type": "playlist", "ids": plsID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'share-scope'`).Execute() + pr := NewPlaylistRepository(adminCtx, b) + _ = pr.Delete(plsID) + mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) + _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) + lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + }) + + It("excludes tracks the owner cannot access from the shared playlist", func() { + // Read the share as admin (mimics the public-share render path, which uses + // the share repository's own context). loadMedia must scope to the owner. + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + share, err := adminRepo.Get("share-scope") + Expect(err).ToNot(HaveOccurred()) + + Expect(share.Tracks).To(ContainElement(HaveField("ID", "share-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "share-other")), + "a track outside the owner's libraries must not appear in the share") + }) + + It("returns no tracks when the playlist is not visible to the owner", func() { + // A private playlist owned by someone else: the share owner can no longer + // see it, so Tracks() returns nil. The share must render with no tracks + // instead of panicking. + privatePlsID := "private-pls" + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + pr := NewPlaylistRepository(adminCtx, GetDBXBuilder()) + privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false} + privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}}) + Expect(pr.Put(privatePls)).To(Succeed()) + DeferCleanup(func() { _ = pr.Delete(privatePlsID) }) + + _, err := GetDBXBuilder().NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "share-private", "user": owner.ID, "desc": "Private share", + "type": "playlist", "ids": privatePlsID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery(`DELETE FROM share WHERE id = 'share-private'`).Execute() }) + + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + share, err := adminRepo.Get("share-private") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Tracks).To(BeEmpty()) + }) + }) + Describe("Ownership Checks", func() { var ownerUser = model.User{ID: "2222", UserName: "regular-user"} var otherUser = model.User{ID: "3333", UserName: "third-user"} @@ -155,7 +251,7 @@ var _ = Describe("ShareRepository", func() { Describe("Delete", func() { It("allows a non-admin user to delete their own share", func() { insertShare("own-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("own-share-del") Expect(err).ToNot(HaveOccurred()) @@ -163,15 +259,21 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from deleting another user's share", func() { insertShare("other-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("other-share-del") Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The share was not deleted: the owner can still read it. + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder()) + _, err = ownerRepo.(rest.Repository).Read("other-share-del") + Expect(err).ToNot(HaveOccurred()) }) It("allows an admin to delete any user's share", func() { insertShare("admin-del-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("admin-del-share") Expect(err).ToNot(HaveOccurred()) @@ -179,7 +281,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to delete a share", func() { insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Delete("headless-del-share") Expect(err).ToNot(HaveOccurred()) }) @@ -188,7 +290,7 @@ var _ = Describe("ShareRepository", func() { Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -196,7 +298,7 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from updating another user's share", func() { insertShare("other-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") Expect(err).To(Equal(rest.ErrPermissionDenied)) @@ -204,7 +306,7 @@ var _ = Describe("ShareRepository", func() { It("allows an admin to update any user's share", func() { insertShare("admin-upd-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -212,10 +314,178 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to update a share", func() { insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) + + It("returns not found when updating a nonexistent share", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + + It("updates all columns when no specific columns are given", func() { + insertShare("all-cols-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + // No cols: the update must write every column, not just updated_at. + err := repo.(rest.Persistable).Update("all-cols-share", + &model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) + Expect(err).ToNot(HaveOccurred()) + + got, err := repo.(rest.Repository).Read("all-cols-share") + Expect(err).ToNot(HaveOccurred()) + share := got.(*model.Share) + Expect(share.Description).To(Equal("All Updated")) + Expect(share.MaxBitRate).To(Equal(192)) + Expect(share.ResourceType).To(Equal("album")) + }) + + It("does not let an owner reassign their share to another user", func() { + insertShare("reassign-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("reassign-share", + &model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") + Expect(err).ToNot(HaveOccurred()) + + // Ownership must not have moved, even though user_id was passed in the body and cols. + got, err := repo.(rest.Repository).Read("reassign-share") + Expect(err).ToNot(HaveOccurred()) + Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) + }) + }) + + Describe("Read scoping", func() { + BeforeEach(func() { + // Persist owner/other users so the JOIN in selectShare resolves. + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + insertShare("share-owner-1", ownerUser.ID) + insertShare("share-owner-2", ownerUser.ID) + insertShare("share-other-1", otherUser.ID) + }) + + Context("non-admin user", func() { + var nonAdminRepo model.ShareRepository + var nonAdminRest rest.Repository + + BeforeEach(func() { + nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder()) + nonAdminRest = nonAdminRepo.(rest.Repository) + }) + + It("GetAll returns only own shares", func() { + shares, err := nonAdminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("ReadAll returns only own shares", func() { + res, err := nonAdminRest.ReadAll() + Expect(err).ToNot(HaveOccurred()) + shares := res.(model.Shares) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("Get returns own share", func() { + s, err := nonAdminRepo.Get("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-owner-1")) + }) + + It("Get returns ErrNotFound for another user's share", func() { + _, err := nonAdminRepo.Get("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Read returns ErrNotFound for another user's share", func() { + _, err := nonAdminRest.Read("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Exists returns true for own share", func() { + exists, err := nonAdminRepo.Exists("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("Exists returns false for another user's share", func() { + exists, err := nonAdminRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("CountAll counts only own shares", func() { + count, err := nonAdminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + + It("Count (rest) counts only own shares", func() { + count, err := nonAdminRest.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + }) + + Context("admin user", func() { + It("GetAll returns all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + shares, err := adminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1")) + }) + + It("CountAll counts all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + count, err := adminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 3)) + }) + }) + + Context("headless context (public share route)", func() { + It("GetAll returns all shares", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(shares).To(HaveLen(3)) + }) + + It("Get returns another user's share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + s, err := headlessRepo.Get("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-other-1")) + }) + + It("Exists returns true for any share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + exists, err := headlessRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) }) }) }) diff --git a/persistence/sort_index_coverage_test.go b/persistence/sort_index_coverage_test.go new file mode 100644 index 000000000..b5dea231d --- /dev/null +++ b/persistence/sort_index_coverage_test.go @@ -0,0 +1,168 @@ +package persistence + +import ( + "context" + "database/sql" + "fmt" + "maps" + "regexp" + "slices" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "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" +) + +// These tests guard against sort options silently losing index support: adding or +// changing a sort mapping, or dropping/renaming an index in a migration, must not +// reintroduce full-table temp B-tree sorts on the large tables. Those are +// catastrophic on big libraries but invisible on dev-sized ones, which is how the +// unindexed album/artist song sorts went unnoticed for years. +// +// Every sort mapping is checked automatically: the real ORDER BY is built via +// buildSortOrder (both directions) and verified with EXPLAIN QUERY PLAN against +// the migrated test schema. The planner's choice is deterministic even on an +// empty table. A sort passes when the plan has no full "USE TEMP B-TREE FOR +// ORDER BY" step; an incremental sort of tie groups ("... FOR LAST TERM OF ORDER +// BY") is fine, as it only sorts rows with equal leading columns. +// +// A new sort mapping therefore fails this test until a matching index is created. +// The only escape hatch is exceptions, for sorts that genuinely cannot be +// served by a table index (random, annotation-join columns, JSON expressions): +// declaring one requires writing down the reason, making the trade-off visible in +// review. The checks run with the default config: PreferSortTags=true rewrites +// mappings to coalesce expressions with no matching indexes (used by ~0.1% of +// installations, per insights), and is out of scope here. +var _ = Describe("Sort index coverage", func() { + conn := db.Db() + + type repoCase struct { + table string + newRepo func(ctx context.Context) *sqlRepository + // sort mapping -> reason it cannot be served by an index + exceptions map[string]string + } + + cases := []repoCase{ + { + table: "media_file", + newRepo: func(ctx context.Context) *sqlRepository { + return &NewMediaFileRepository(ctx, GetDBXBuilder()).(*mediaFileRepository).sqlRepository + }, + exceptions: map[string]string{ + "random": "not a column sort", + "starred_at": "sorts on annotation join columns", + "rated_at": "sorts on annotation join columns", + "play_count": "sorts on annotation join columns", + "play_date": "sorts on annotation join columns", + "rating": "sorts on annotation join columns", + "comment": "UI-sortable but rarely used; not worth an index", + }, + }, + { + table: "album", + newRepo: func(ctx context.Context) *sqlRepository { + return &NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository).sqlRepository + }, + exceptions: map[string]string{ + "random": "not a column sort", + "starred_at": "sorts on annotation join columns", + "rated_at": "sorts on annotation join columns", + "max_year": "coalesce expression over original_date/max_year, no expression index", + }, + }, + { + table: "artist", + newRepo: func(ctx context.Context) *sqlRepository { + return &NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository).sqlRepository + }, + exceptions: map[string]string{ //nolint:gosec // G101 false positive, same as the artist sortMappings + "starred_at": "sorts on annotation join columns", + "rated_at": "sorts on annotation join columns", + "song_count": "JSON expression over stats column", + "album_count": "JSON expression over stats column", + "size": "JSON expression over stats column", + "maincredit_song_count": "aggregate over JSON stats", + "maincredit_album_count": "aggregate over JSON stats", + "maincredit_size": "aggregate over JSON stats", + }, + }, + } + + newCtx := func() context.Context { + ctx := log.NewContext(GinkgoT().Context()) + return request.WithUser(ctx, model.User{ID: "userid"}) + } + + for _, c := range cases { + It(fmt.Sprintf("uses an index for every sort mapping on %s", c.table), func() { + r := c.newRepo(newCtx()) + for _, sort := range slices.Sorted(maps.Keys(r.sortMappings)) { + if _, ok := c.exceptions[sort]; ok { + continue + } + for _, dir := range []string{"asc", "desc"} { + orderBy := r.buildSortOrder(sort, dir) + Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(), + "sort %q (%s) on table %q needs an index. Create one matching its ORDER BY, or, if it cannot be served by an index, add it to exceptions with the reason", + sort, dir, c.table) + } + } + }) + + It(fmt.Sprintf("has no stale exceptions entries for %s", c.table), func() { + r := c.newRepo(newCtx()) + for _, sort := range slices.Sorted(maps.Keys(c.exceptions)) { + Expect(r.sortMappings).To(HaveKey(sort), + "exceptions entry %q on table %q does not match any sort mapping - remove it", sort, c.table) + } + }) + } + + It("uses an index for recently_added when RecentlyAddedByModTime is enabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.RecentlyAddedByModTime = true + for _, c := range cases[:2] { // media_file and album + r := c.newRepo(newCtx()) + for _, dir := range []string{"asc", "desc"} { + orderBy := r.buildSortOrder("recently_added", dir) + Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(), + "sort recently_added (%s) on table %q", dir, c.table) + } + } + }) +}) + +// Matches the full-sort step only: incremental tie-group sorts are reported as +// "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (or "LAST N TERMS") and are allowed. +var fullTempBTreeSort = regexp.MustCompile(`USE TEMP B-TREE FOR ORDER BY`) + +func checkSortUsesIndex(conn *sql.DB, table, orderBy string) error { + rows, err := conn.Query(fmt.Sprintf("explain query plan select * from %s order by %s limit 15", table, orderBy)) + if err != nil { + return fmt.Errorf("explain query plan failed for order by %q: %w", orderBy, err) + } + defer rows.Close() + + var details []string + for rows.Next() { + var id, parent, notUsed int + var detail string + if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { + return err + } + details = append(details, detail) + } + if err := rows.Err(); err != nil { + return err + } + if slices.ContainsFunc(details, fullTempBTreeSort.MatchString) { + return fmt.Errorf("no index satisfies ORDER BY %s - plan: %v", orderBy, details) + } + return nil +} diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 07bd96975..46ad6a0de 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -4,17 +4,61 @@ import ( "database/sql" "errors" "fmt" + "regexp" + "sort" "strings" + "sync" "time" . "github.com/Masterminds/squirrel" + "github.com/fatih/structs" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" ) const annotationTable = "annotation" +// annotationColumns are the columns withAnnotation's LEFT JOIN contributes, derived from +// model.Annotations so the set tracks schema changes. average_rating is excluded: it lives on the +// base table, not the annotation join. +var annotationColumns = sync.OnceValue(func() map[string]struct{} { + cols := map[string]struct{}{} + for name := range structs.Map(model.Annotations{}) { + if name == "average_rating" { + continue + } + cols[name] = struct{}{} + } + return cols +}) + +// annotationColumnRE matches any annotation column as a whole word. The word boundaries keep the +// base-table column average_rating from matching the annotation column rating (Go's \b treats '_' +// as a word char). It is case-insensitive because SQLite column names are, so a raw filter using +// e.g. "RATING" must still be detected. +var annotationColumnRE = sync.OnceValue(func() *regexp.Regexp { + cols := make([]string, 0, len(annotationColumns())) + for col := range annotationColumns() { + cols = append(cols, regexp.QuoteMeta(col)) + } + sort.Strings(cols) // map iteration is random; sort for a stable pattern + return regexp.MustCompile(`(?i)\b(?:` + strings.Join(cols, "|") + `)\b`) +}) + +// filtersNeedAnnotation reports whether the rendered query references an annotation column, i.e. +// whether the annotation LEFT JOIN must be kept. Scanning the rendered SQL catches every filter +// path. The placeholder column is needed because squirrel won't render a column-less SELECT; on a +// render error, keep the join to be safe. +func filtersNeedAnnotation(query SelectBuilder) bool { + sql, _, err := query.Columns("1").ToSql() + if err != nil { + return true + } + return annotationColumnRE().MatchString(sql) +} + func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { userID := loggedUser(r.ctx).ID if userID == invalidUserId { @@ -23,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", diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 15efc5dc7..5766f687f 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -150,4 +150,98 @@ var _ = Describe("Annotation Filters", func() { } Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored") }) + + Describe("annotationColumns", func() { + It("derives the annotation join columns from model.Annotations, excluding average_rating", func() { + cols := annotationColumns() + Expect(cols).To(HaveKey("starred")) + Expect(cols).To(HaveKey("starred_at")) + Expect(cols).To(HaveKey("rating")) + Expect(cols).To(HaveKey("rated_at")) + Expect(cols).To(HaveKey("play_count")) + Expect(cols).To(HaveKey("play_date")) + Expect(cols).To(HaveLen(6), "expected exactly the 6 annotation-join columns") + Expect(cols).ToNot(HaveKey("average_rating"), "average_rating lives on the base table, not the annotation join") + }) + }) + + Describe("filtersNeedAnnotation", func() { + It("is true when the query references an annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"starred": true}) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is true for a raw expression referencing an annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("rating > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is false for a query that references no annotation column", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"missing": false}) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + + It("is false for a filter on average_rating (base-table column, not the annotation rating)", func() { + // Regression: average_rating must not match the annotation column "rating". + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Gt{"average_rating": 3}) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + + It("is true when both average_rating and a real annotation column are referenced", func() { + q := squirrel.Select("count(1)").From("media_file"). + Where(squirrel.Gt{"average_rating": 3}). + Where(squirrel.Expr("COALESCE(rating, 0) > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is true for uppercase/mixed-case annotation columns (SQLite is case-insensitive)", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("RATING > 0")) + Expect(filtersNeedAnnotation(q)).To(BeTrue()) + }) + + It("is false for uppercase average_rating (still excluded case-insensitively)", func() { + q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("AVERAGE_RATING > 3")) + Expect(filtersNeedAnnotation(q)).To(BeFalse()) + }) + }) + + Describe("CountAll annotation-join gating", func() { + It("counts all items unfiltered (join dropped)", func() { + total, err := albumRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(total).To(BeNumerically(">=", int64(1))) + + filtered, err := albumRepo.CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(filtered).To(Equal(int64(1))) + }) + + It("counts starred items correctly (named annotation filter keeps the join)", func() { + starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1} + Expect(albumRepo.Put(&starredAlbum)).To(Succeed()) + Expect(albumRepo.SetStar(true, starredAlbum.ID)).To(Succeed()) + defer func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID})) + }() + + // Exactly two albums are starred for this user: the one created above and + // albumRadioactivity (id 103) from the seed data. + count, err := albumRepo.CountAll(model.QueryOptions{ + Filters: annotationBoolFilter("starred")("starred", "true"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) + }) + + It("counts via a raw annotation filter without a 'no such column' error", func() { + count, err := albumRepo.CountAll(model.QueryOptions{ + Filters: squirrel.Expr("COALESCE(rating, 0) > 0"), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", int64(0))) + }) + }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index fd263d37b..ce5221d19 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -57,6 +58,33 @@ func loggedUser(ctx context.Context) *model.User { } } +// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for +// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid +// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +// +// The predicate uses an unqualified user_id, so it only works on queries where that column is +// unambiguous (no join introducing a second user_id). +func (r sqlRepository) ownerFilter() Sqlizer { + if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { + return Eq{"user_id": usr.ID} + } + return nil +} + +// addRestriction combines an optional caller predicate with the ownership filter, producing the +// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and +// only the caller's predicate (if any) remains. +func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer { + s := And{} + if len(sql) > 0 { + s = append(s, sql[0]) + } + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) + } + return s +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -186,15 +214,6 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti return sq } -func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { - return func(field string, value any) Sqlizer { - if r.tableName != "" { - field = r.tableName + "." + field - } - return filter(field, value) - } -} - // libraryIdFilter is a filter function to be added to resources that have a library_id column. func libraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_id": value} @@ -210,6 +229,12 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) return sq } + // A non-admin granted every library sees everything the subquery would return, so applying it is + // pure overhead. Skip it in that case (same fast path admins get). + if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + return sq + } + table := r.tableName if len(tableName) > 0 { table = tableName[0] @@ -221,6 +246,32 @@ func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID)) } +// userSeesAllLibraries reports whether the visible set already covers every library, so a +// library filter would exclude nothing. +func (r sqlRepository) userSeesAllLibraries(visible []int) bool { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + return true // visible is the whole library table + } + total, err := NewLibraryRepository(r.ctx, r.db).CountAll() + if err != nil || total == 0 { + return false + } + return int64(len(visible)) == total +} + +// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and +// headless processes, otherwise the user's granted libraries. +func (r sqlRepository) visibleLibraryIDs() ([]int, error) { + user := loggedUser(r.ctx) + if user.IsAdmin || user.ID == invalidUserId { + var ids []int + err := r.queryAllSlice(Select("id").From("library"), &ids) + return ids, err + } + return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil +} + func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick @@ -382,6 +433,65 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) { return res.Exist > 0, err } +// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only update rows they own: the +// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply +// does not match and no write happens. Ownership itself is immutable here: user_id is never written, +// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put, +// it never falls through to an INSERT, so a non-matching id never creates a row. +// +// When the update matches no row it classifies the failure: if the row exists but is owned by +// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is +// still atomic; the extra lookup happens only on the failure path (count == 0), where no write +// occurred, so there is no TOCTOU on the update. +func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error { + values, err := toSQLArgs(m) + if err != nil { + return fmt.Errorf("error preparing values to write to DB: %w", err) + } + updateValues := filterUpdateValues(values, id, colsToUpdate...) + delete(updateValues, "user_id") // ownership is immutable on update + update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues) + count, err := r.executeSQL(update) + if err != nil { + return err + } + if count == 0 { + return r.classifyOwnedWriteMiss(id) + } + return nil +} + +// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only delete rows they own: the +// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply +// does not match and is left untouched. The failure path mirrors updateOwned (see +// classifyOwnedWriteMiss), so there is no TOCTOU on the delete. +func (r sqlRepository) deleteOwned(id string) error { + count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id}))) + if err != nil { + return err + } + if count == 0 { + return r.classifyOwnedWriteMiss(id) + } + return nil +} + +// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched +// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise +// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred. +func (r sqlRepository) classifyOwnedWriteMiss(id string) error { + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). @@ -408,6 +518,30 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } +// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the +// row identified by id: only the requested colsToUpdate (or all columns when none are specified), +// dropping columns that must never be overwritten on update (created_at, birth_time). +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := map[string]any{} + + // This is a map of the columns that need to be updated, if specified + c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { + return toSnakeCase(s), struct{}{} + }) + for k, v := range values { + if _, found := c2upd[k]; len(c2upd) == 0 || found { + updateValues[k] = v + } + } + + updateValues["id"] = id + delete(updateValues, "created_at") + // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now + // TODO move to mediafile_repository when each repo has its own upsert method + delete(updateValues, "birth_time") + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { @@ -415,24 +549,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri } // If there's an ID, try to update first if id != "" { - updateValues := map[string]any{} - - // This is a map of the columns that need to be updated, if specified - c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { - return toSnakeCase(s), struct{}{} - }) - for k, v := range values { - if _, found := c2upd[k]; len(c2upd) == 0 || found { - updateValues[k] = v - } - } - - updateValues["id"] = id - delete(updateValues, "created_at") - // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now - // TODO move to mediafile_repository when each repo has its own upsert method - delete(updateValues, "birth_time") - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) count, err := r.executeSQL(update) if err != nil { return "", err diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index b46e2066b..9c6c6007f 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -226,9 +226,21 @@ var _ = Describe("sqlRepository", func() { Describe("applyLibraryFilter", func() { var sq squirrel.SelectBuilder + var savedDB = r.db BeforeEach(func() { sq = squirrel.Select("*").From("test_table") + // Add library 2 so a user granted only library 1 is a genuine strict subset. + savedDB = r.db + r.db = GetDBXBuilder() + _, err := r.db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Lib 2', '/lib2')").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + _, err := r.db.NewQuery("DELETE FROM library WHERE id = 2").Execute() + Expect(err).ToNot(HaveOccurred()) + r.db = savedDB }) Context("Admin User", func() { @@ -238,31 +250,82 @@ var _ = Describe("sqlRepository", func() { It("should not apply library filter for admin users", func() { result := r.applyLibraryFilter(sq) - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) - Context("Regular User", func() { + Context("Regular User with a subset of libraries", func() { BeforeEach(func() { - r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false}) + // Strict subset: granted lib 1, DB has libs 1 and 2, so the filter must apply. + r.ctx = request.WithUser(context.Background(), model.User{ + ID: "user123", IsAdmin: false, Libraries: model.Libraries{{ID: 1}}, + }) }) It("should apply library filter for regular users", func() { result := r.applyLibraryFilter(sq) - sql, args, _ := result.ToSql() + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) Expect(args).To(ContainElement("user123")) }) It("should use custom table name when provided", func() { result := r.applyLibraryFilter(sq, "custom_table") - sql, args, _ := result.ToSql() + sql, args, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("custom_table.library_id IN")) Expect(args).To(ContainElement("user123")) }) }) + Context("Regular User with no libraries", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "empty", IsAdmin: false}) + }) + + It("should apply the library filter (never skip on empty)", func() { + result := r.applyLibraryFilter(sq) + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) + }) + }) + + Context("Regular User who can see all libraries", func() { + BeforeEach(func() { + // Grant every library that currently exists in the (shared) DB, so the filter + // would exclude nothing. Querying the real IDs keeps this correct even if other + // specs left extra libraries behind, which happens under Ginkgo's randomized order. + var ids []int + err := r.db.NewQuery("SELECT id FROM library ORDER BY id").Column(&ids) + Expect(err).ToNot(HaveOccurred()) + libs := make(model.Libraries, 0, len(ids)) + for _, id := range ids { + libs = append(libs, model.Library{ID: id}) + } + r.ctx = request.WithUser(context.Background(), model.User{ + ID: "alllibs", IsAdmin: false, Libraries: libs, + }) + }) + + It("should not apply the library filter (subquery would filter nothing)", func() { + result := r.applyLibraryFilter(sq) + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + + It("should not apply the filter even with a custom table name", func() { + result := r.applyLibraryFilter(sq, "custom_table") + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + Context("Headless Process (No User Context)", func() { BeforeEach(func() { r.ctx = context.Background() // No user context @@ -270,13 +333,15 @@ var _ = Describe("sqlRepository", func() { It("should not apply library filter for headless processes", func() { result := r.applyLibraryFilter(sq) - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) It("should not apply library filter even with custom table name", func() { result := r.applyLibraryFilter(sq, "custom_table") - sql, _, _ := result.ToSql() + sql, _, err := result.ToSql() + Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 02162387c..1dcabcec6 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -46,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query continue } // Default to a "starts with" filter - filters = append(filters, startsWithFilter(f, v)) + filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)}) } return filters } @@ -91,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer { return Eq{field: value} } -func startsWithFilter(field string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%s%%", value)} +func startsWithFilter(field string) func(string, any) Sqlizer { + return func(_ string, value any) Sqlizer { + return Like{field: fmt.Sprintf("%s%%", value)} + } } func containsFilter(field string) func(string, any) Sqlizer { diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 43965ebb7..3049baae7 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -1,6 +1,7 @@ package persistence import ( + "fmt" "strings" . "github.com/Masterminds/squirrel" @@ -20,8 +21,9 @@ type searchConfig struct { NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid") OrderBy []string // ORDER BY for text search results (e.g. ["name"]) MBIDFields []string // columns to match when query is a UUID - // LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1. - // Needed when library access requires a junction table (e.g. artist → library_artist). + // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1, for entities whose + // library access goes through a junction table (e.g. artist → library_artist). It MUST be join-free + // (Phase 1 has no DISTINCT, so a fan-out JOIN would corrupt offset pagination). See [artistLibraryFilter]. LibraryFilter func(sq SelectBuilder) SelectBuilder } @@ -57,8 +59,8 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order. if q == "" || q == `""` { - sq = sq.OrderBy(cfg.NaturalOrder) - return r.queryAll(sq, results, options) + rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder) + return r.executeTwoPhase(sq, results, rowidCore, cfg, options) } // MBID search: if query is a valid UUID, search by MBID fields instead @@ -82,6 +84,50 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea return strategy.execute(r, sq, results, cfg, options) } +// executeTwoPhase runs a search in two phases: +// - Phase 1: rowidCore (strategy-specific FROM/JOINs and ORDER BY) plus the shared search +// contract applied here — non-missing rows only, library access, options.Filters, and +// pagination. Keeping Phase 1 free of the full SELECT's JOINs lets SQLite paginate via a +// covering index; with those JOINs, large offsets degrade to O(offset) join probes — +// multi-second responses on 100k+ libraries. +// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid page. +func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error { + rowidQuery := rowidCore. + Where(Eq{r.tableName + ".missing": false}) + if options.Max > 0 { + rowidQuery = rowidQuery.Limit(uint64(options.Max)) + } + if options.Offset > 0 { + rowidQuery = rowidQuery.Offset(uint64(options.Offset)) + } + if cfg.LibraryFilter != nil { + rowidQuery = cfg.LibraryFilter(rowidQuery) + } else { + rowidQuery = r.applyLibraryFilter(rowidQuery) + } + if options.Filters != nil { + rowidQuery = rowidQuery.Where(options.Filters) + } + return r.hydrateRowidPage(sq, rowidQuery, results) +} + +// hydrateRowidPage joins sq to the ordered rowid set produced by rowidQuery, preserving its +// ordering. rowidQuery must handle pagination itself; sq's LIMIT/OFFSET are stripped. +func (r sqlRepository) hydrateRowidPage(sq SelectBuilder, rowidQuery SelectBuilder, results any) error { + rowidSQL, rowidArgs, err := rowidQuery.ToSql() + if err != nil { + return fmt.Errorf("building rowid query: %w", err) + } + sq = sq.RemoveLimit().RemoveOffset() + rankedSubquery := fmt.Sprintf( + "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked", + rowidSQL, + ) + sq = sq.Join(rankedSubquery+" ON "+r.tableName+".rowid = _ranked._rid", rowidArgs...) + sq = sq.OrderBy("_ranked._rn") + return r.queryAll(sq, results) +} + func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { if uuid.Validate(mbid) != nil || len(mbidFields) == 0 { return nil diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index e9b961d91..fce77afbb 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -11,6 +11,7 @@ import ( "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" ) // containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters. @@ -35,48 +36,12 @@ func containsCJK(s string) bool { // as unbalanced string delimiters. var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`) -// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes). -// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM"). -var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`) - // fts5Operators matches FTS5 boolean operators as whole words (case-insensitive). var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) // fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) -// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of -// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) -// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed -// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — -// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree -// without an explicit transliterated entry here. -func normalizeForFTS(values ...string) string { - seen := make(map[string]struct{}) - var result []string - add := func(orig, variant string) { - if variant == "" || variant == orig { - return - } - lower := strings.ToLower(variant) - if _, ok := seen[lower]; ok { - return - } - seen[lower] = struct{}{} - result = append(result, variant) - } - for _, v := range values { - for _, word := range strings.Fields(v) { - transliterated := sanitize.Accents(word) - // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. - add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) - // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). - add(word, transliterated) - } - } - return strings.Join(result, " ") -} - // isSingleUnicodeLetter returns true if token is exactly one Unicode letter. func isSingleUnicodeLetter(token string) bool { r, size := utf8.DecodeRuneInString(token) @@ -100,7 +65,7 @@ func processPunctuatedWords(input string, phrases []string) (string, []string) { result = append(result, w) continue } - concat := fts5PunctStrip.ReplaceAllString(w, "") + concat := str.FTSPunctStrip.ReplaceAllString(w, "") if concat == "" || concat == w { result = append(result, w) continue @@ -140,13 +105,15 @@ func isDottedAbbreviation(w string, subTokens []string) bool { } // buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression. +// Plain tokens are emitted as (token OR token*) so bm25 ranks exact-token hits above prefix-only matches. // It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators // (by lowercasing them, since FTS5 operators are case-sensitive) and strips // special characters to prevent query injection. -func buildFTS5Query(userInput string) string { +// The second return reports whether tokenization degraded the query (see ftsQueryDegraded). +func buildFTS5Query(userInput string) (string, bool) { q := strings.TrimSpace(userInput) if q == "" || q == `""` { - return "" + return "", false } var phrases []string @@ -186,25 +153,38 @@ func buildFTS5Query(userInput string) string { result = fts5LeadingStar.ReplaceAllString(result, "$1") tokens := strings.Fields(result) - // Append * to plain tokens for prefix matching (e.g., "love" → "love*"). - // Skip tokens that are already wildcarded or are quoted phrase placeholders. + // Two forms per token: a plain prefix form (love*) used only to evaluate query + // degradation, and the final (love OR love*) form. The OR adds no matches + // (exact ⊂ prefix) but gives bm25 a high-IDF exact-term hit, ranking rows that + // contain the literal word above prefix-only matches. Placeholders and + // user-supplied wildcards pass through untouched in both forms. + prefixTokens := make([]string, len(tokens)) + wrappedTokens := make([]string, len(tokens)) for i, t := range tokens { if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") { + prefixTokens[i], wrappedTokens[i] = t, t continue } - tokens[i] = t + "*" + prefixTokens[i] = t + "*" + wrappedTokens[i] = "(" + t + " OR " + t + "*)" } // Use explicit AND between tokens — FTS5's implicit AND (space-separated) - // doesn't work correctly with parenthesized OR groups from processPunctuatedWords. - result = strings.Join(tokens, " AND ") + // doesn't work correctly with parenthesized OR groups. The prefix form is + // space-joined instead: it only feeds ftsQueryDegraded, which would count a + // literal "AND" as a long token and never flag all-short-token queries. + prefixQuery := strings.Join(prefixTokens, " ") + result = strings.Join(wrappedTokens, " AND ") for i, phrase := range phrases { placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) + prefixQuery = strings.ReplaceAll(prefixQuery, placeholder, phrase) result = strings.ReplaceAll(result, placeholder, phrase) } - return result + // Degradation is evaluated on the prefix form: ftsQueryDegraded treats + // leading-( tokens as punctuated-word groups and would never flag wrapped ones. + return result, ftsQueryDegraded(userInput, prefixQuery) } // ftsColumn pairs an FTS5 column name with its BM25 relevance weight. @@ -244,7 +224,10 @@ var ftsColumnDefs = map[string][]ftsColumn{ "artist": { {"name", 10.0}, {"sort_artist_name", 1.0}, - {"search_normalized", 1.0}, + // Same weight as name: for artists this column is purely the name in + // alternate spelling (unlike media_file/album, where it mixes + // title/album/artist variants and full weight would distort ranking). + {"search_normalized", 10.0}, }, } @@ -279,16 +262,14 @@ type ftsSearch struct { } // ToSql returns a single-query fallback for the REST filter path (no two-phase split). -func (s *ftsSearch) ToSql() (string, []interface{}, error) { +func (s *ftsSearch) ToSql() (string, []any, error) { sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" - return sql, []interface{}{s.matchExpr}, nil + return sql, []any{s.matchExpr}, nil } -// execute runs a two-phase FTS5 search: -// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination. -// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set. -// -// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1. +// execute runs a two-phase FTS5 search (see executeTwoPhase): Phase 1 here contributes the +// FTS MATCH join and BM25 rank ordering. Complex ORDER BY (function calls, aggregations) are +// dropped from Phase 1. func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { qualifiedOrderBys := []string{s.rankExpr} for _, ob := range cfg.OrderBy { @@ -297,45 +278,11 @@ func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg sea } } - // Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly. - // Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0. - rowidQuery := Select(s.tableName+".rowid"). + rowidCore := Select(s.tableName+".rowid"). From(s.tableName). Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr). - Where(Eq{s.tableName + ".missing": false}). OrderBy(qualifiedOrderBys...) - if options.Max > 0 { - rowidQuery = rowidQuery.Limit(uint64(options.Max)) - } - if options.Offset > 0 { - rowidQuery = rowidQuery.Offset(uint64(options.Offset)) - } - - // Library filter + musicFolderId must be applied here, before pagination. - if cfg.LibraryFilter != nil { - rowidQuery = cfg.LibraryFilter(rowidQuery) - } else { - rowidQuery = r.applyLibraryFilter(rowidQuery) - } - if options.Filters != nil { - rowidQuery = rowidQuery.Where(options.Filters) - } - - rowidSQL, rowidArgs, err := rowidQuery.ToSql() - if err != nil { - return fmt.Errorf("building FTS rowid query: %w", err) - } - - // Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination), - // join on the ranked rowid set to hydrate with full columns. - sq = sq.RemoveLimit().RemoveOffset() - rankedSubquery := fmt.Sprintf( - "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked", - rowidSQL, - ) - sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...) - sq = sq.OrderBy("_ranked._rn") - return r.queryAll(sq, dest) + return r.executeTwoPhase(sq, dest, rowidCore, cfg, options) } // qualifyOrderBy prepends tableName to a simple column name. Returns empty string for @@ -365,7 +312,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Strip quotes from original for comparison — we want the raw content stripped := strings.ReplaceAll(original, `"`, "") // Extract the alphanumeric content from the original query - alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "") + alphaNum := str.FTSPunctStrip.ReplaceAllString(stripped, "") // If the original is entirely alphanumeric, nothing was stripped — not degraded if len(alphaNum) == len(stripped) { return false @@ -373,8 +320,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Check if all effective FTS tokens are very short (≤2 chars). // Short tokens with prefix matching are too broad when special chars were stripped. // For quoted phrases, extract the content and check the tokens inside. - tokens := strings.Fields(ftsQuery) - for _, t := range tokens { + tokens := strings.FieldsSeq(ftsQuery) + for t := range tokens { t = strings.TrimSuffix(t, "*") // Skip internal phrase placeholders if strings.HasPrefix(t, "\x00") { @@ -389,8 +336,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { if strings.HasPrefix(t, `"`) { // Extract content between quotes inner := strings.Trim(t, `"`) - innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") - for _, it := range strings.Fields(innerAlpha) { + innerAlpha := str.FTSPunctStrip.ReplaceAllString(inner, " ") + for it := range strings.FieldsSeq(innerAlpha) { if len(it) > 2 { return false } @@ -409,8 +356,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // tokenization stripped significant content from the query (e.g., "1+" → "1*"). // Returns nil when the query produces no searchable tokens at all. func newFTSSearch(tableName, query string) searchStrategy { - q := buildFTS5Query(query) - if q == "" || ftsQueryDegraded(query, q) { + q, degraded := buildFTS5Query(query) + if q == "" || degraded { // Fallback: try LIKE search with the raw query cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, "")) if cleaned != "" { diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index b54e5856a..d0b26e8d5 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -12,44 +12,45 @@ import ( var _ = DescribeTable("buildFTS5Query", func(input, expected string) { - Expect(buildFTS5Query(input)).To(Equal(expected)) + q, _ := buildFTS5Query(input) + Expect(q).To(Equal(expected)) }, Entry("returns empty string for empty input", "", ""), Entry("returns empty string for whitespace-only input", " ", ""), - Entry("appends * to a single word for prefix matching", "beatles", "beatles*"), - Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"), - Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`), - Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"), - Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"), - Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"), - Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`), - Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"), - Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"), - Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"), - Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"), + Entry("wraps a single word as exact OR prefix", "beatles", "(beatles OR beatles*)"), + Entry("wraps each word as exact OR prefix", "abbey road", "(abbey OR abbey*) AND (road OR road*)"), + Entry("preserves quoted phrases without wrapping", `"the beatles"`, `"the beatles"`), + Entry("does not wrap user-supplied prefix wildcard", "beat*", "beat*"), + Entry("strips FTS5 operators and wraps lowercased words", "AND OR NOT NEAR", "(and OR and*) AND (or OR or*) AND (not OR not*) AND (near OR near*)"), + Entry("strips special FTS5 syntax characters and wraps", "test^col:val", "(test OR test*) AND (col OR col*) AND (val OR val*)"), + Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND (abbey OR abbey*)`), + Entry("handles prefix with multiple words", "beat* abbey", "beat* AND (abbey OR abbey*)"), + Entry("collapses multiple spaces", "abbey road", "(abbey OR abbey*) AND (road OR road*)"), + Entry("strips leading * from tokens and wraps", "*livia", "(livia OR livia*)"), + Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "(livia OR livia*) AND oliv*"), Entry("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"), + Entry("strips apostrophe from input", "Guns N' Roses", "(Guns OR Guns*) AND (N OR N*) AND (Roses OR Roses*)"), Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), - Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), - Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), - Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), - Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"), - Entry("transliterates ø to o", "Øystein", "Oystein*"), - Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"), - Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"), - Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"), - Entry("transliterates ß to ss", "Straße", "Strasse*"), + Entry("handles punctuated word mixed with plain words", "best of a-ha", `(best OR best*) AND (of OR of*) AND ("a ha" OR aha*)`), + Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND (got OR got*)`), + Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "(rock OR rock*) AND (roll OR roll*) AND (vol OR vol*) AND (2 OR 2*)"), + Entry("transliterates NFKD-decomposable diacritics", "Björk début", "(Bjork OR Bjork*) AND (debut OR debut*)"), + Entry("transliterates ø to o", "Øystein", "(Oystein OR Oystein*)"), + Entry("transliterates œ ligature to oe", "œuvre", "(oeuvre OR oeuvre*)"), + Entry("transliterates æ ligature to ae", "Brennæ", "(Brennae OR Brennae*)"), + Entry("transliterates mixed unicode words", "Mø Sigur Rós", "(Mo OR Mo*) AND (Sigur OR Sigur*) AND (Ros OR Ros*)"), + Entry("transliterates ß to ss", "Straße", "(Strasse OR Strasse*)"), Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), - Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `(best OR best*) AND (of OR of*) AND "R E M"`), Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), - Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"), - Entry("does not collapse single standalone letter", "A test", "A* AND test*"), + Entry("does not collapse single letter surrounded by words", "I am fine", "(I OR I*) AND (am OR am*) AND (fine OR fine*)"), + Entry("does not collapse single standalone letter", "A test", "(A OR A*) AND (test OR test*)"), Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), @@ -57,6 +58,20 @@ var _ = DescribeTable("buildFTS5Query", Entry("returns empty string for empty quoted phrase", `""`, ""), ) +var _ = DescribeTable("buildFTS5Query degraded flag", + func(input string, expected bool) { + _, degraded := buildFTS5Query(input) + Expect(degraded).To(Equal(expected)) + }, + Entry("plain words are not degraded", "beatles", false), + Entry("special chars stripped leaving short token is degraded", "1+", true), + Entry("multiple short tokens are degraded", "1+ 2+", true), + Entry("short tokens mixed with a long word are not degraded", "1+ beatles", false), + Entry("quoted short-token phrase is degraded", `"1+"`, true), + Entry("punctuated-name group is not degraded", "AC/DC", false), + Entry("empty input is not degraded", "", false), +) + var _ = DescribeTable("ftsQueryDegraded", func(original, ftsQuery string, expected bool) { Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected)) @@ -74,28 +89,6 @@ var _ = DescribeTable("ftsQueryDegraded", Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false), ) -var _ = DescribeTable("normalizeForFTS", - func(expected string, values ...string) { - Expect(normalizeForFTS(values...)).To(Equal(expected)) - }, - Entry("strips dots and concatenates", "REM", "R.E.M."), - Entry("strips slash", "ACDC", "AC/DC"), - Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged ASCII words", "", "The Beatles"), - Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), - Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), - Entry("strips apostrophe from word", "N", "Guns N' Roses"), - Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), - Entry("transliterates ø to o", "Bjork", "Bjørk"), - Entry("transliterates Ø to O", "Oystein", "Øystein"), - Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), - Entry("transliterates Latin diacritics", "cafe", "café"), - Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), - Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), - Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), - Entry("transliterates ß to ss", "Strasse", "Straße"), -) - var _ = DescribeTable("containsCJK", func(input string, expected bool) { Expect(containsCJK(input)).To(Equal(expected)) @@ -165,7 +158,7 @@ var _ = Describe("ftsColumnDefs helpers", func() { It("returns weight CSV for artist", func() { Expect(ftsBM25Weights).To(HaveKeyWithValue("artist", - "10.0, 1.0, 1.0", + "10.0, 1.0, 10.0", )) }) @@ -259,18 +252,18 @@ var _ = Describe("newFTSSearch", func() { Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank")) }) - It("wraps query with column filter for known tables", func() { + It("wraps query with column filter", func() { strategy := newFTSSearch("artist", "Beatles") fts, ok := strategy.(*ftsSearch) Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) + Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : ((Beatles OR Beatles*))")) }) It("passes query without column filter for unknown tables", func() { strategy := newFTSSearch("unknown_table", "test") fts, ok := strategy.(*ftsSearch) Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(Equal("test*")) + Expect(fts.matchExpr).To(Equal("(test OR test*)")) }) It("preserves phrase queries inside column filter", func() { @@ -447,4 +440,38 @@ var _ = Describe("FTS5 Integration Search", func() { Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) }) + + Describe("Exact-match ranking", func() { + BeforeEach(func() { + // Registered before the inserts so a mid-loop failure cannot leak corpus rows. + DeferCleanup(func() { + // library_artist rows are removed by the artist_id ON DELETE CASCADE FK. + _, err := GetDBXBuilder().NewQuery("DELETE FROM artist WHERE id LIKE 'fts-rank-%'").Execute() + Expect(err).ToNot(HaveOccurred()) + }) + // Corpus has no competing exact-word names ("Mo X"): exact-vs-exact order depends + // on corpus statistics; the guaranteed property is exact > prefix. + for _, a := range []model.Artist{ + {ID: "fts-rank-1", Name: "MØ", OrderArtistName: "mø"}, + {ID: "fts-rank-2", Name: "Modest Mouse", OrderArtistName: "modest mouse"}, + {ID: "fts-rank-3", Name: "Morrissey", OrderArtistName: "morrissey"}, + } { + Expect(createArtistWithLibrary(arr, &a, 1)).To(Succeed()) + } + }) + + It("ranks the exact transliterated match first for 'MO'", func() { + results, err := arr.Search("MO", model.QueryOptions{Max: 10}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Name).To(Equal("MØ"), "exact match via search_normalized must outrank prefix matches") + }) + + It("ranks the exact match first for the accented query 'MØ'", func() { + results, err := arr.Search("MØ", model.QueryOptions{Max: 10}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).ToNot(BeEmpty()) + Expect(results[0].Name).To(Equal("MØ")) + }) + }) }) diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go index 769a911d5..972545ac5 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -16,7 +16,7 @@ type likeSearch struct { filter Sqlizer } -func (s *likeSearch) ToSql() (string, []interface{}, error) { +func (s *likeSearch) ToSql() (string, []any, error) { return s.filter.ToSql() } diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 870da61c8..96fd3efdb 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -53,14 +53,29 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro } func (r *transcodingRepository) Read(id string) (any, error) { - return r.Get(id) + res, err := r.Get(id) + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + res.Command = "" + } + return res, nil } func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") res := model.Transcodings{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + for i := range res { + res[i].Command = "" + } + } + return res, nil } func (r *transcodingRepository) EntityName() string { diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index eddc5047a..73250163c 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() { _, err = adminRepo.Get("to-delete") Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("reads the Command field via the REST Read method", func() { + tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := adminRepo.(*transcodingRepository).Read("adminread") + Expect(err).ToNot(HaveOccurred()) + Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret")) + }) }) Describe("Regular User", func() { + It("reads a transcoding but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).Read("readreg") + Expect(err).ToNot(HaveOccurred()) + t := res.(*model.Transcoding) + Expect(t.Name).To(Equal("temp")) + Expect(t.TargetFormat).To(Equal("test_format")) + Expect(t.Command).To(BeEmpty()) + }) + + It("lists transcodings but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).ReadAll() + Expect(err).ToNot(HaveOccurred()) + list := res.(model.Transcodings) + Expect(list).ToNot(BeEmpty()) + for _, t := range list { + Expect(t.Command).To(BeEmpty()) + } + }) + + It("counts transcodings", func() { + count, err := repo.(*transcodingRepository).Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", 0)) + }) + + It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() { + tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.Get("streamreg") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("streamreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + + It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() { + tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.FindByFormat("test_format") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("fmtreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + It("fails to create", func() { err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) Expect(err).To(Equal(rest.ErrPermissionDenied)) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index dc149e8ba..9decff4e5 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -59,7 +59,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r.registerModel(&model.User{}, map[string]filterFunc{ "id": idFilter(r.tableName), "password": invalidFilter(ctx), - "name": r.withTableName(startsWithFilter), + "name": startsWithFilter(r.tableName + ".name"), }) once.Do(func() { _ = r.initPasswordEncryptionKey() diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 8abbf76a9..6f8ab9161 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -207,6 +208,52 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("ReadAll name filter", func() { + var adminRepo model.ResourceRepository + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) + adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository) + + for _, u := range []model.User{ + {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, + {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, + } { + Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed()) + } + }) + + AfterEach(func() { + ur := adminRepo.(model.UserRepository) + _ = ur.Delete("filter-alice") + _ = ur.Delete("filter-bob") + }) + + It("matches users whose name starts with the given prefix", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + var names []string + for _, u := range users { + names = append(names, u.Name) + } + Expect(names).To(ContainElement("Alice Filter")) + Expect(names).ToNot(ContainElement("Bob Filter")) + }) + + It("does not match names by mid-string substring (startsWith, not contains)", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + for _, u := range users { + Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), + "a mid-string substring should not match a startsWith filter") + } + }) + }) + Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User diff --git a/plugins/README.md b/plugins/README.md index 048cf549d..b9118d36f 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1030,8 +1030,6 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` -**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). - ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md index fca3cbd31..2ad4a82da 100644 --- a/plugins/capabilities/README.md +++ b/plugins/capabilities/README.md @@ -76,7 +76,7 @@ The YAML schemas in this package are automatically generated from the capability To regenerate the schemas after modifying the interfaces, run: ```bash -cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities +cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types ``` ## Resources diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 04dd283dd..7336124fc 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -9,20 +9,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name GetLyricsRequest: description: GetLyricsRequest contains the track information for lyrics lookup. properties: @@ -124,3 +110,31 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 407f21ec5..f856562c6 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // MetadataAgent provides artist and album metadata retrieval. // This capability allows plugins to provide external metadata for artists and albums, // such as biographies, images, similar artists, and top songs. @@ -102,10 +104,13 @@ type SimilarArtistsRequest struct { Limit int32 `json:"limit"` } +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // ImageInfo represents an image with URL and size. @@ -134,32 +139,13 @@ type TopSongsRequest struct { Count int32 `json:"count"` } -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` -} +// Deprecated: use types.SongRef. +type SongRef = types.SongRef // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // AlbumRequest is the common request for album-related functions. @@ -233,5 +219,5 @@ type SimilarSongsByArtistRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index 4940a5056..6e528c7e7 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -173,20 +173,6 @@ components: description: MBID is the MusicBrainz ID for the artist. required: - mbid - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name ArtistRequest: description: ArtistRequest is the common request for artist-related functions. properties: @@ -331,39 +317,6 @@ components: $ref: '#/components/schemas/SongRef' required: - songs - SongRef: - description: SongRef is a reference to a song with metadata for matching. - properties: - id: - type: string - description: ID is the internal Navidrome mediafile ID (if known). - name: - type: string - description: Name is the song name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the song. - isrc: - type: string - description: ISRC is the International Standard Recording Code for the song. - artist: - type: string - description: Artist is the artist name. - artistMbid: - type: string - description: ArtistMBID is the MusicBrainz artist ID. - album: - type: string - description: Album is the album name. - albumMbid: - type: string - description: AlbumMBID is the MusicBrainz release ID. - duration: - type: number - format: float - description: Duration is the song duration in seconds. - required: - - name TopSongsRequest: description: TopSongsRequest is the request for GetArtistTopSongs. properties: @@ -394,3 +347,91 @@ components: $ref: '#/components/schemas/SongRef' required: - songs + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name + SongRef: + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: |- + Artist is the artist name. + + Deprecated: use Artists. + artistMbid: + type: string + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. + artists: + type: array + description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + items: + $ref: '#/components/schemas/ArtistRef' + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. + required: + - name diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 4918d5e8f..c1c05839a 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // Scrobbler provides scrobbling functionality to external services. // This capability allows plugins to submit listening history to services like Last.fm, // ListenBrainz, or custom scrobbling backends. @@ -32,16 +34,6 @@ type IsAuthorizedRequest struct { Username string `json:"username"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. @@ -55,9 +47,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 9d5cfed30..2b862b964 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -25,20 +25,6 @@ exports: contentType: application/json components: schemas: - ArtistRef: - description: ArtistRef is a reference to an artist with name and optional MBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - name IsAuthorizedRequest: description: IsAuthorizedRequest is the request for authorization check. properties: @@ -194,3 +180,31 @@ components: - duration - trackNumber - discNumber + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go index aadb9396e..a35d9d923 100644 --- a/plugins/capabilities/sonic_similarity.go +++ b/plugins/capabilities/sonic_similarity.go @@ -1,5 +1,7 @@ package capabilities +import "github.com/navidrome/navidrome/plugins/types" + // SonicSimilarity provides audio-similarity based track discovery. // //nd:capability name=sonicsimilarity required=true @@ -12,14 +14,14 @@ type SonicSimilarity interface { } type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } type SonicSimilarityResponse struct { @@ -27,6 +29,6 @@ type SonicSimilarityResponse struct { } type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index cba97d9b0..a336fbce8 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -39,39 +39,6 @@ components: required: - song - count - SongRef: - description: SongRef is a reference to a song with metadata for matching. - properties: - id: - type: string - description: ID is the internal Navidrome mediafile ID (if known). - name: - type: string - description: Name is the song name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the song. - isrc: - type: string - description: ISRC is the International Standard Recording Code for the song. - artist: - type: string - description: Artist is the artist name. - artistMbid: - type: string - description: ArtistMBID is the MusicBrainz artist ID. - album: - type: string - description: Album is the album name. - albumMbid: - type: string - description: AlbumMBID is the MusicBrainz release ID. - duration: - type: number - format: float - description: Duration is the song duration in seconds. - required: - - name SonicMatch: properties: song: @@ -90,3 +57,91 @@ components: $ref: '#/components/schemas/SonicMatch' required: - matches + ArtistRef: + description: |- + ArtistRef is the minimal information a plugin returns for Navidrome to match an + artist against the library. It is a reference, not a full artist entity: it + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never + descriptive data such as biographies or images. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID (if known). + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). + required: + - name + SongRef: + description: |- + SongRef is the minimal information exchanged between a plugin and Navidrome to + match a song. It is used both as input (a song Navidrome already has) and as + output (a song a plugin suggests, which may not be in the library yet). Unlike + Track, it is an abstract recording reference carrying only matching keys (IDs, + ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: |- + Artist is the artist name. + + Deprecated: use Artists. + artistMbid: + type: string + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. + artists: + type: array + description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + items: + $ref: '#/components/schemas/ArtistRef' + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. + required: + - name diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md index d2f67a60c..7487db892 100644 --- a/plugins/cmd/ndpgen/README.md +++ b/plugins/cmd/ndpgen/README.md @@ -7,7 +7,7 @@ This tool is the unified code generator that handle both host function wrappers ## Usage ```bash -ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust] +ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-rust] ``` ### Flags @@ -21,10 +21,9 @@ ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] | `-dry-run` | Parse and validate without writing files | `false` | | `-host-only` | Generate only host function wrappers (capability support TBD) | `true` | | `-go` | Generate Go client wrappers | `true`* | -| `-python` | Generate Python client wrappers | `false` | | `-rust` | Generate Rust client wrappers | `false` | -\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages. +\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages. ### Example @@ -150,10 +149,6 @@ func TestMyPluginFunction(t *testing.T) { If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations. -### Python Client Library - -When using `-python`, Python client files are generated in a `python/` subdirectory. - ### Rust Client Library When using `-rust`, Rust client files are generated in a `rust/` subdirectory. diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod index 4af62658e..6b081a826 100644 --- a/plugins/cmd/ndpgen/go.mod +++ b/plugins/cmd/ndpgen/go.mod @@ -7,7 +7,7 @@ require ( github.com/onsi/ginkgo/v2 v2.27.5 github.com/onsi/gomega v1.39.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - golang.org/x/tools v0.41.0 + golang.org/x/tools v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,9 +18,9 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum index 952672d0e..828273cab 100644 --- a/plugins/cmd/ndpgen/go.sum +++ b/plugins/cmd/ndpgen/go.sum @@ -54,18 +54,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index db500c1fc..d8bc5859d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -176,16 +176,15 @@ type ServiceB interface { Describe("code generation", func() { DescribeTable("generates correct client output", - func(serviceFile, goClientExpectedFile, pyClientExpectedFile, rsClientExpectedFile string) { + func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { serviceCode := readTestdata(serviceFile) goClientExpected := readTestdata(goClientExpectedFile) - pyClientExpected := readTestdata(pyClientExpectedFile) rsClientExpected := readTestdata(rsClientExpectedFile) Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - // Generate all client code (Go, Python, Rust) - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust") + // Generate all client code (Go, Rust) + cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) @@ -217,17 +216,6 @@ type ServiceB interface { Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch") - // Verify Python client code (now in $output/python/host/) - pythonHostDir := filepath.Join(outputDir, "python", "host") - pyClientEntries, err := os.ReadDir(pythonHostDir) - Expect(err).ToNot(HaveOccurred()) - Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file") - - pyClientActual, err := os.ReadFile(filepath.Join(pythonHostDir, pyClientEntries[0].Name())) - Expect(err).ToNot(HaveOccurred()) - - Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch") - // Verify Rust client code (now in $output/rust/nd-pdk-host/src/) rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src") rsClientEntries, err := os.ReadDir(rustSrcDir) @@ -251,39 +239,59 @@ type ServiceB interface { }, Entry("simple string params", - "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"), + "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), Entry("multiple simple params (int32)", - "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"), + "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), Entry("struct param with request type", - "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"), + "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), Entry("mixed simple and complex params", - "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"), + "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), Entry("method without error", - "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"), + "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), Entry("no params, error only", - "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"), + "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), Entry("map and interface types", - "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"), + "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), Entry("pointer types", - "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"), + "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), Entry("multiple returns", - "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"), + "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), Entry("bytes", - "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"), + "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), Entry("option pattern (value, exists bool)", - "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), + "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), ) + It("generates the shared Go types package with -shared-types", func() { + typesSrc := `package types + +// ArtistRef references an artist. +type ArtistRef struct { + ID string ` + "`json:\"id,omitempty\"`" + ` + Name string ` + "`json:\"name\"`" + ` +} +` + Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed()) + cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go") + out, err := cmd.CombinedOutput() + Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out) + + content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("package types")) + Expect(string(content)).To(ContainSubstring("type ArtistRef struct {")) + }) + It("generates compilable client code for comprehensive service", func() { serviceCode := readTestdata("comprehensive_service.go.txt") @@ -386,119 +394,6 @@ var _ = ndpdk.ComprehensiveNoParams Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile()) }) - It("generates Python client code with -python flag", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - - pythonFile := filepath.Join(pythonHostDir, "nd_host_test.py") - Expect(pythonFile).To(BeAnExistingFile()) - - content, err := os.ReadFile(pythonFile) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):")) - Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`)) - Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:")) - }) - - It("generates both Go and Python client code with -go -python flags", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Go client code exists in $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile()) - - // Verify Python client code exists in $output/python/host/ - pythonHostDir := filepath.Join(outputDir, "python", "host") - Expect(pythonHostDir).To(BeADirectory()) - Expect(filepath.Join(pythonHostDir, "nd_host_test.py")).To(BeAnExistingFile()) - }) - - It("generates Python code with dataclass for multi-value returns", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Cache permission=cache -type CacheService interface { - //nd:hostfunc - GetString(ctx context.Context, key string) (value string, exists bool, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_cache.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("@dataclass")) - Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(contentStr).To(ContainSubstring("value: str")) - Expect(contentStr).To(ContainSubstring("exists: bool")) - Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - }) - - It("generates Python code for methods with no parameters", func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Ping(ctx context.Context) (status string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-python") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - content, err := os.ReadFile(filepath.Join(outputDir, "python", "host", "nd_host_test.py")) - Expect(err).ToNot(HaveOccurred()) - - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("def test_ping() -> str:")) - Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) }) }) diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 705cd4d36..50f53cd37 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -4,6 +4,7 @@ import ( "bytes" "embed" "fmt" + "slices" "strings" "text/template" ) @@ -79,17 +80,6 @@ func mockAccessor(typ string, idx int) string { } } -// pythonFuncMap returns the template functions for Python client code generation. -func pythonFuncMap(svc Service) template.FuncMap { - return template.FuncMap{ - "lower": strings.ToLower, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) }, - "pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) }, - "pythonDefault": pythonDefaultValue, - } -} - // GenerateHost generates the host function wrapper code for a service. func GenerateHost(svc Service, pkgName string) ([]byte, error) { tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl") @@ -186,51 +176,13 @@ func formatDoc(doc string) string { return strings.Join(result, "\n") } -// GenerateClientPython generates Python client wrapper code for plugins. -func GenerateClientPython(svc Service) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client.py.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Python client template: %w", err) - } - - tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// pythonDefaultValue returns a Python default value for response.get() calls. -func pythonDefaultValue(p Param) string { - switch p.Type { - case "string": - return `, ""` - case "int", "int32", "int64": - return ", 0" - case "float32", "float64": - return ", 0.0" - case "bool": - return ", False" - case "[]byte": - return ", b\"\"" - default: - return ", None" - } -} - // rustFuncMap returns the template functions for Rust client code generation. func rustFuncMap(svc Service) template.FuncMap { knownStructs := svc.KnownStructs() + shared := make(map[string]string) + for _, a := range svc.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "lower": strings.ToLower, "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, @@ -238,9 +190,9 @@ func rustFuncMap(svc Service) template.FuncMap { "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, "rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) }, "rustDocComment": RustDocComment, - "rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) }, - "rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, + "rustType": func(p Param) string { return p.RustTypeWithShared(knownStructs, shared) }, + "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) }, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, } } @@ -388,6 +340,18 @@ func indentText(n int, s string) string { return strings.Join(lines, "\n") } +// indentSpaces adds n spaces to each non-empty line of text. +func indentSpaces(spaces int, s string) string { + ind := strings.Repeat(" ", spaces) + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = ind + line + } + } + return strings.Join(lines, "\n") +} + // capabilityAgentName returns the interface name for a capability. // Uses the Go interface name stripped of common suffixes. func capabilityAgentName(cap Capability) string { @@ -459,6 +423,10 @@ func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) { // rustCapabilityFuncMap returns template functions for Rust capability code generation. func rustCapabilityFuncMap(cap Capability) template.FuncMap { knownStructs := cap.KnownStructs() + shared := make(map[string]string) + for _, a := range cap.SharedAliases { + shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) + } return template.FuncMap{ "rustDocComment": RustDocComment, "rustTypeAlias": rustTypeAlias, @@ -466,25 +434,25 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap { "rustConstName": rustConstName, "rustFieldName": func(name string) string { return ToSnakeCase(name) }, "rustMethodName": func(name string) string { return ToSnakeCase(name) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, - "rustOutputType": rustOutputType, - "isPrimitiveRust": isPrimitiveRustType, + "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, + "rustOutputType": func(goType string) string { return rustTraitType(goType, shared) }, + "rustMethodType": func(goType string) string { return rustMethodType(goType, cap.Name, shared) }, "skipSerializingFunc": skipSerializingFunc, "hasHashMap": hasHashMap, "agentName": capabilityAgentName, "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, - "snakeCase": ToSnakeCase, - "indent": func(spaces int, s string) string { - indent := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") + "rustSharedTarget": func(target string) string { + return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix) }, + // rustSharedNote is the human-facing path for deprecation notes: plugin + // authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types + // as `types`, so they reference these via nd_pdk::types::X. + "rustSharedNote": func(target string) string { + return "nd_pdk::types::" + strings.TrimPrefix(target, sharedTypesPrefix) + }, + "snakeCase": ToSnakeCase, + "indent": indentSpaces, } } @@ -526,6 +494,46 @@ func rustConstType(goType string) string { // TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly. // Currently "*string" returns "string" instead of "String". This would generate invalid // Rust code. No current capability uses this pattern, but it should be fixed if needed. +// rustMethodType returns the fully-qualified Rust type for a capability method +// input/output as referenced inside the generated export macro. The macro expands +// in the downstream plugin crate, which depends on the umbrella nd-pdk crate and +// not on nd-pdk-types directly, so shared types must be reached through $crate +// (the defining nd-pdk-capabilities crate, which re-exports nd_pdk_types as +// `types`) rather than by naming the transitive crate. Primitives map to their +// Rust name; any other named type is a capability-local struct, qualified as +// $crate::::X. This is used instead of hand-assembling +// "$crate::::" + rustOutputType, which produced invalid paths like +// "$crate::demo::types.SongRef" for shared types used directly in a signature. +func rustMethodType(goType, pkg string, shared map[string]string) string { + goType = strings.TrimPrefix(goType, "*") + if isPrimitiveRustType(goType) { + return rustOutputType(goType) + } + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "$crate::types::" + rest + } + if t, ok := shared[goType]; ok { + return "$crate::types::" + strings.TrimPrefix(t, "nd_pdk_types::") + } + return "$crate::" + ToSnakeCase(pkg) + "::" + goType +} + +// rustTraitType returns the Rust type for a capability trait method signature. +// The trait lives in the capability module alongside its local structs, so those +// stay bare; shared types must still resolve to their nd_pdk_types::X crate path +// (a shared type used directly in a signature would otherwise pass through as the +// invalid Go selector "types.SongRef"). +func rustTraitType(goType string, shared map[string]string) string { + stripped := strings.TrimPrefix(goType, "*") + if rest, ok := strings.CutPrefix(stripped, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + if t, ok := shared[stripped]; ok { + return t + } + return rustOutputType(goType) +} + func rustOutputType(goType string) string { // Strip pointer prefix - capability outputs use Result for optionality if strings.HasPrefix(goType, "*") { @@ -568,9 +576,16 @@ func rustConstName(name string) string { } // skipSerializingFunc returns the appropriate skip_serializing_if function name. +// The check must match the rendered Rust type: pointers become Option, slices Vec, +// and maps HashMap, each with a different emptiness predicate. func skipSerializingFunc(goType string) string { - if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") { + switch { + case strings.HasPrefix(goType, "*"): return "Option::is_none" + case strings.HasPrefix(goType, "[]"): + return "Vec::is_empty" + case strings.HasPrefix(goType, "map["): + return "HashMap::is_empty" } switch goType { case "string": @@ -594,9 +609,9 @@ func skipSerializingFunc(goType string) string { } } -// hasHashMap returns true if any struct in the capability uses HashMap. -func hasHashMap(cap Capability) bool { - for _, st := range cap.Structs { +// anyFieldUsesHashMap returns true if any field in the given structs uses a map type. +func anyFieldUsesHashMap(structs []StructDef) bool { + for _, st := range structs { for _, f := range st.Fields { if strings.HasPrefix(f.Type, "map[") { return true @@ -606,6 +621,32 @@ func hasHashMap(cap Capability) bool { return false } +// anyFieldIsByteSlice reports whether any field across the given structs is a +// []byte, which Go's JSON encoder serializes as a base64 string. The Rust +// shared-types crate must match that with a base64_bytes serde override. +func anyFieldIsByteSlice(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if f.IsByteSlice() { + return true + } + } + } + return false +} + +// hasHashMap returns true if any struct in the capability uses HashMap. +func hasHashMap(cap Capability) bool { + return anyFieldUsesHashMap(cap.Structs) +} + +// sortedStructs returns a sorted copy of structs, ordered by name. +func sortedStructs(structs []StructDef) []StructDef { + sorted := append([]StructDef(nil), structs...) + slices.SortFunc(sorted, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return sorted +} + // registerMacroName returns the macro name for registering an optional method. // For package "websocket" and method "OnClose", returns "register_websocket_close". func registerMacroName(pkg, name string) string { @@ -662,6 +703,11 @@ func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) { buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n") buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n") + // Re-export the shared types so generated registration macros can reference them + // via $crate::types::X. The macro expands in the downstream plugin crate, which + // depends on the umbrella nd-pdk crate and not on nd-pdk-types directly. + buf.WriteString("pub use nd_pdk_types as types;\n\n") + // Module declarations for _, cap := range capabilities { moduleName := ToSnakeCase(cap.Name) @@ -887,3 +933,70 @@ func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) { return buf.Bytes(), nil } + +// GenerateSharedTypesRust generates the nd-pdk-types crate root (lib.rs). +func GenerateSharedTypesRust(structs []StructDef) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types rust template: %w", err) + } + sorted := sortedStructs(structs) + known := map[string]bool{} + for _, s := range sorted { + known[s.Name] = true + } + tmpl, err := template.New("types_rs").Funcs(template.FuncMap{ + "rustDocComment": RustDocComment, + "rustFieldName": func(n string) string { return ToSnakeCase(n) }, + "fieldRustType": func(f FieldDef) string { return f.RustType(known) }, + "skipSerializingFunc": skipSerializingFunc, + "indent": indentSpaces, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading base64_bytes partial: %w", err) + } + tmpl, err = tmpl.Parse(string(partialContent)) + if err != nil { + return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) + } + + data := struct { + Structs []StructDef + HasHashMap bool + HasByteFields bool + }{Structs: sorted, HasHashMap: anyFieldUsesHashMap(sorted), HasByteFields: anyFieldIsByteSlice(sorted)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} + +// GenerateSharedTypesGo generates the shared `types` package (plain data structs). +func GenerateSharedTypesGo(structs []StructDef, pkgName string) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/types.go.tmpl") + if err != nil { + return nil, fmt.Errorf("reading types template: %w", err) + } + tmpl, err := template.New("types").Funcs(template.FuncMap{ + "formatDoc": formatDoc, + "indent": indentText, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + data := struct { + Package string + Structs []StructDef + }{Package: pkgName, Structs: sortedStructs(structs)} + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + return buf.Bytes(), nil +} diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 34c2c2886..15b97e1ae 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -287,6 +287,42 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`"encoding/json"`)) Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) + + It("imports the shared types package when a method references types directly", func() { + svc := Service{ + Name: "Matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("songs", "[]types.SongRef")}, + Returns: []Param{NewParam("results", "[]*types.Track")}, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + Expect(codeStr).To(ContainSubstring("Songs []types.SongRef")) + }) + + It("does not import the shared types package when no method references types", func() { + svc := Service{ + Name: "Test", + Interface: "TestService", + Methods: []Method{ + {Name: "Method", Params: []Param{NewParam("count", "int32")}}, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + }) }) Describe("toJSONName", func() { @@ -418,266 +454,19 @@ var _ = Describe("Generator", func() { }) }) - Describe("Python type and name helpers", func() { - Describe("ToPythonType", func() { - It("should map Go types to Python types", func() { - Expect(ToPythonType("string")).To(Equal("str")) - Expect(ToPythonType("int")).To(Equal("int")) - Expect(ToPythonType("int32")).To(Equal("int")) - Expect(ToPythonType("int64")).To(Equal("int")) - Expect(ToPythonType("float32")).To(Equal("float")) - Expect(ToPythonType("float64")).To(Equal("float")) - Expect(ToPythonType("bool")).To(Equal("bool")) - Expect(ToPythonType("[]byte")).To(Equal("bytes")) - Expect(ToPythonType("unknown")).To(Equal("Any")) - }) + Describe("ToSnakeCase", func() { + It("should convert PascalCase to snake_case", func() { + Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) + Expect(ToSnakeCase("GetString")).To(Equal("get_string")) + Expect(ToSnakeCase("simple")).To(Equal("simple")) }) - Describe("ToSnakeCase", func() { - It("should convert PascalCase to snake_case", func() { - Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) - Expect(ToSnakeCase("GetString")).To(Equal("get_string")) - Expect(ToSnakeCase("simple")).To(Equal("simple")) - }) - - It("should handle acronyms correctly", func() { - Expect(ToSnakeCase("ID")).To(Equal("id")) - Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) - Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) - Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) - Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) - }) - }) - - Describe("Method.PythonFunctionName", func() { - It("should generate snake_case function name with service prefix", func() { - m := Method{Name: "GetString"} - Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string")) - }) - }) - - Describe("Param.PythonType", func() { - It("should return Python type for parameter", func() { - p := NewParam("value", "string") - Expect(p.PythonType()).To(Equal("str")) - }) - }) - - Describe("Param.PythonName", func() { - It("should return snake_case name for parameter", func() { - p := NewParam("ttlSeconds", "int64") - Expect(p.PythonName()).To(Equal("ttl_seconds")) - }) - }) - }) - - Describe("GenerateClientPython", func() { - It("should generate valid Python code for a simple service", func() { - svc := Service{ - Name: "SubsonicAPI", - Permission: "subsonicapi", - Interface: "SubsonicAPIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("responseJSON", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for imports - Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass")) - Expect(codeStr).To(ContainSubstring("import extism")) - Expect(codeStr).To(ContainSubstring("import json")) - - // Check for exception class - Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):")) - - // Check for raw import function - Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`)) - Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:")) - - // Check for wrapper function with type hints - Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:")) - - // Check for error handling - Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response[")) - }) - - It("should generate dataclass for multi-value returns", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Methods: []Method{ - { - Name: "GetString", - HasError: true, - Params: []Param{NewParam("key", "string")}, - Returns: []Param{ - NewParam("value", "string"), - NewParam("exists", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for dataclass - Expect(codeStr).To(ContainSubstring("@dataclass")) - Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("value: str")) - Expect(codeStr).To(ContainSubstring("exists: bool")) - - // Check that function returns dataclass - Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:")) - Expect(codeStr).To(ContainSubstring("return CacheGetStringResult(")) - }) - - It("should handle methods with no parameters", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoParams", - HasError: true, - Returns: []Param{NewParam("result", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function with no params - Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:")) - // Empty request - Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`)) - }) - - It("should handle methods with no return values", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoReturn", - HasError: true, - Params: []Param{NewParam("input", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Function returns None - Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:")) - }) - - It("should generate correct Python defaults for different types", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "AllTypes", - HasError: true, - Returns: []Param{ - NewParam("strVal", "string"), - NewParam("intVal", "int64"), - NewParam("floatVal", "float64"), - NewParam("boolVal", "bool"), - }, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check defaults in response.get() calls - Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`)) - Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) - Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) - }) - - It("should not import base64 for non-byte services", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - Expect(codeStr).NotTo(ContainSubstring("import base64")) - }) - - It("should generate base64 encoding/decoding for byte fields", func() { - svc := Service{ - Name: "Codec", - Permission: "codec", - Interface: "CodecService", - Methods: []Method{ - { - Name: "Encode", - HasError: true, - Params: []Param{NewParam("data", "[]byte")}, - Returns: []Param{NewParam("result", "[]byte")}, - }, - }, - } - - code, err := GenerateClientPython(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should import base64 - Expect(codeStr).To(ContainSubstring("import base64")) - - // Should base64-encode byte params in request - Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`)) - - // Should base64-decode byte returns in response - Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`)) + It("should handle acronyms correctly", func() { + Expect(ToSnakeCase("ID")).To(Equal("id")) + Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) + Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) + Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) + Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) }) }) @@ -1186,6 +975,73 @@ type TestService interface { Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType =")) Expect(codeStr).To(ContainSubstring(`"retry"`)) }) + + It("emits a deprecated alias and types import for a shared-aliased capability", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}, + }}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo.")) + Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo")) + Expect(out).NotTo(ContainSubstring("type TrackInfo struct")) + }) + + It("emits the types import for a direct types.X field with no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", + Interface: "Scrobbler", + Required: true, + Methods: []Export{{ + Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}, + }}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}, + }}}, + // No SharedAliases: the field references the canonical type directly. + } + code, err := GenerateCapabilityGo(cap, "scrobbler") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) + + It("emits the types import for a direct types.X method input/output", func() { + cap := Capability{ + Name: "demo", + Interface: "Demo", + Required: true, + Methods: []Export{{ + Name: "Lookup", ExportName: "nd_demo_lookup", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}, + }}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityGo(cap, "demo") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) + Expect(out).To(ContainSubstring("types.SongRef")) + }) }) Describe("GenerateCapabilityGoStub", func() { @@ -1287,14 +1143,67 @@ type OnInitOutput struct { }) }) +var _ = Describe("Shared Types Generation", func() { + It("emits a Rust types crate root with serde derives", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};")) + Expect(out).To(ContainSubstring("pub struct ArtistRef {")) + Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`)) + Expect(out).To(ContainSubstring("pub name: String,")) + }) + + It("emits a flat Go types package with no imports", func() { + structs := []StructDef{ + {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, + {Name: "Name", Type: "string", JSONTag: "name"}, + }}, + } + code, err := GenerateSharedTypesGo(structs, "types") + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("package types")) + Expect(out).To(ContainSubstring("type ArtistRef struct {")) + Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`")) + Expect(out).To(ContainSubstring("Name string `json:\"name\"`")) + Expect(out).NotTo(ContainSubstring("import")) + }) + + It("emits base64 serde for Vec fields in the Rust types crate", func() { + structs := []StructDef{ + {Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{ + {Name: "Data", Type: "[]byte", JSONTag: "data"}, + }}, + } + code, err := GenerateSharedTypesRust(structs) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + Expect(out).To(ContainSubstring("mod base64_bytes")) + Expect(out).To(ContainSubstring("use base64::Engine as _")) + Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) + }) +}) + var _ = Describe("Rust Generation", func() { Describe("skipSerializingFunc", func() { - It("should return Option::is_none for pointer, slice, and map types", func() { + It("should return Option::is_none for pointer types", func() { Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none")) + }) + + It("should return the matching emptiness predicate for slice and map types", func() { + // The predicate must match the rendered Rust type: []T -> Vec, map[K]V -> HashMap. + Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty")) + Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty")) + Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty")) }) It("should return String::is_empty for string type", func() { @@ -1482,6 +1391,109 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("Option<")) }) + It("translates a shared type used directly as a method input/output", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + // No structs, no aliases: the method signature references the shared type directly. + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The shared type must resolve to the canonical crate path, not pass through + // as the invalid Go selector `types.SongRef`. + Expect(out).To(ContainSubstring("nd_pdk_types::SongRef")) + Expect(out).NotTo(ContainSubstring("types.SongRef")) + }) + + It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", + Input: Param{Name: "input", Type: "types.SongRef"}, + Output: Param{Name: "output", Type: "types.SongRef"}}}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Inside the export macro (expanded in the downstream plugin crate, which depends + // on the umbrella nd-pdk only), the shared type must be reachable via $crate, not + // by naming the transitive nd_pdk_types crate directly. + Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>")) + Expect(out).NotTo(ContainSubstring("extism_pdk::Json")) + }) + + It("emits a deprecated Rust type alias for shared types", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`); + // the alias target stays the real crate path so it resolves inside nd-pdk-capabilities. + Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`)) + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;")) + }) + + It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() { + // Wrapper has a field whose type is only in SharedAliases, not Structs. + // The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical + // path), not as the local deprecated alias and not as serde_json::Value. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Doc: "Deprecated: use types.TrackInfo.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field must use the canonical nd_pdk_types:: path, not the local alias. + Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,")) + }) + + It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() { + // The capability references the shared type by its canonical qualified + // name (types.Track) while the deprecated alias keeps the old name. + cap := Capability{ + Name: "test", Interface: "TestAgent", Required: true, + Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", + Input: Param{Name: "req", Type: "Wrapper"}}}, + Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Doc: "Deprecated: use types.Track.", + }}, + } + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // Field uses the canonical qualified path (resolved from the types. prefix). + Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,")) + // The deprecated alias is still re-exported under its kept name. + Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;")) + Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) + }) + It("should include all float types correctly", func() { cap := Capability{ Name: "test", @@ -1633,6 +1645,36 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) }) + It("resolves a shared alias used in a method param/return to its canonical crate path", func() { + svc := Service{ + Name: "Matcher", + Permission: "matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("query", "string")}, + // Return uses the deprecated alias name directly. + Returns: []Param{NewParam("matches", "[]Track")}, + }, + }, + SharedAliases: []SharedAlias{{ + Name: "Track", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + out := string(code) + // The alias must resolve to the shared crate type; a bare `Track` is undefined + // in nd-pdk-host and would not compile. + Expect(out).To(ContainSubstring("nd_pdk_types::Track")) + Expect(out).NotTo(ContainSubstring("Vec")) + }) + It("should not generate base64 module when no byte fields", func() { svc := Service{ Name: "Test", diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d4..9f305efec 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -27,31 +27,86 @@ var ( keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`) ) -// ParseDirectory parses all Go source files in a directory and extracts host services. -func ParseDirectory(dir string) ([]Service, error) { +// parsedGoFile pairs a source path with its already-parsed AST. +type parsedGoFile struct { + path string + file *ast.File +} + +// parseGoFiles returns the eligible Go source files in dir, each parsed once. +func parseGoFiles(dir string, fset *token.FileSet) ([]parsedGoFile, error) { + paths, err := goSourceFiles(dir) + if err != nil { + return nil, err + } + out := make([]parsedGoFile, 0, len(paths)) + for _, p := range paths { + f, err := parser.ParseFile(fset, p, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(p), err) + } + out = append(out, parsedGoFile{path: p, file: f}) + } + return out, nil +} + +// goSourceFiles returns the Go source file paths in dir, excluding generated, +// test, and doc files. +func goSourceFiles(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading directory: %w", err) } - - var services []Service - fset := token.NewFileSet() - + var paths []string for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { continue } - // Skip generated files and test files - if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") { + if strings.HasSuffix(name, "_gen.go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" { continue } + paths = append(paths, filepath.Join(dir, name)) + } + return paths, nil +} - path := filepath.Join(dir, entry.Name()) - parsed, err := parseFile(fset, path) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) +// ParseDirectory parses all Go source files in a directory and extracts host services. +func ParseDirectory(dir string) ([]Service, error) { + return ParseDirectoryWithShared(dir, nil) +} + +// ParseDirectoryWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) + if err != nil { + return nil, err + } + + // First pass: collect all struct definitions and type aliases from every file + // so that a struct or alias declared in one file is visible when resolving + // types in a sibling file. + pkgStructMap := make(map[string]StructDef) + pkgAliasMap := make(map[string]TypeAlias) + for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s } - services = append(services, parsed...) + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a + } + } + + // Second pass: parse services using the package-level maps. + var services []Service + for _, pf := range parsed { + svcList, err := parseServiceFile(pf.file, pkgStructMap, pkgAliasMap, shared) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) + } + services = append(services, svcList...) } return services, nil @@ -59,66 +114,72 @@ func ParseDirectory(dir string) ([]Service, error) { // ParseCapabilities parses all Go source files in a directory and extracts capabilities. func ParseCapabilities(dir string) ([]Capability, error) { - entries, err := os.ReadDir(dir) + return ParseCapabilitiesWithShared(dir, nil) +} + +// ParseCapabilitiesWithShared parses all Go source files in a directory, resolving any +// type aliases that reference the shared `types` package against the provided registry. +func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) { + fset := token.NewFileSet() + parsed, err := parseGoFiles(dir, fset) if err != nil { - return nil, fmt.Errorf("reading directory: %w", err) + return nil, err } - fset := token.NewFileSet() - - // First pass: collect all structs and type aliases from all files in the package - sharedStructMap := make(map[string]StructDef) - sharedAliasMap := make(map[string]TypeAlias) + // First pass: collect all structs, type aliases, and const groups. + pkgStructMap := make(map[string]StructDef) + pkgAliasMap := make(map[string]TypeAlias) var allConstGroups []ConstGroup - var goFiles []string - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { - continue + for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s } - // Skip generated files, test files, and doc.go - if strings.HasSuffix(entry.Name(), "_gen.go") || - strings.HasSuffix(entry.Name(), "_test.go") || - entry.Name() == "doc.go" { - continue + for _, a := range parseTypeAliases(pf.file) { + pkgAliasMap[a.Name] = a } - goFiles = append(goFiles, filepath.Join(dir, entry.Name())) + allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...) } - for _, path := range goFiles { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing %s for types: %w", filepath.Base(path), err) - } - for _, s := range parseStructs(f) { - sharedStructMap[s.Name] = s - } - for _, a := range parseTypeAliases(f) { - sharedAliasMap[a.Name] = a - } - allConstGroups = append(allConstGroups, parseConstGroups(f)...) - } - - // Second pass: parse capabilities using the shared type maps + // Second pass: parse capabilities using the package-level type maps. var capabilities []Capability - for _, path := range goFiles { - parsed, err := parseCapabilityFile(fset, path, sharedStructMap, sharedAliasMap, allConstGroups) + for _, pf := range parsed { + capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared) if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) } - capabilities = append(capabilities, parsed...) + capabilities = append(capabilities, capList...) } return capabilities, nil } -// parseCapabilityFile parses a single Go source file and extracts capabilities. -func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup) ([]Capability, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err +// LoadSharedTypes parses every struct defined in dir (the shared `types` source +// package) and returns them keyed by name. dir == "" yields an empty map. +func LoadSharedTypes(dir string) (map[string]StructDef, error) { + result := map[string]StructDef{} + if dir == "" { + return result, nil } + paths, err := goSourceFiles(dir) + if err != nil { + return nil, fmt.Errorf("reading shared types directory: %w", err) + } + fset := token.NewFileSet() + for _, path := range paths { + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + } + for _, s := range parseStructs(f) { + result[s.Name] = s + } + } + return result, nil +} +// parseCapabilityFile parses a single Go source file and extracts capabilities. +func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup, shared map[string]StructDef) ([]Capability, error) { var capabilities []Capability for _, decl := range f.Decls { @@ -190,6 +251,21 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // Recursively collect all struct dependencies collectAllStructDependencies(referencedTypes, structMap) + // Resolve shared-type aliases against the registry + sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared) + if err != nil { + return nil, err + } + capability.SharedAliases = sharedAliases + capability.SharedTypes = sharedTypes + + // Build a set of names already covered by SharedAliases so we don't + // emit them again in TypeAliases (which would cause a redeclaration). + sharedAliasNames := make(map[string]bool, len(capability.SharedAliases)) + for _, sa := range capability.SharedAliases { + sharedAliasNames[sa.Name] = true + } + // Sort type names for stable output order sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes)) @@ -200,8 +276,11 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] } } - // Attach referenced type aliases + // Attach referenced type aliases (skip those already in SharedAliases) for _, typeName := range sortedTypeNames { + if sharedAliasNames[typeName] { + continue + } if a, exists := aliasMap[typeName]; exists { capability.TypeAliases = append(capability.TypeAliases, a) } @@ -211,6 +290,9 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] // This supports error types that are not directly referenced in method signatures interfaceName := typeSpec.Name.Name for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) { + if sharedAliasNames[typeName] { + continue + } a := aliasMap[typeName] if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] { capability.TypeAliases = append(capability.TypeAliases, a) @@ -237,6 +319,111 @@ func parseCapabilityFile(fset *token.FileSet, path string, structMap map[string] return capabilities, nil } +// resolveSharedAliases determines which shared `types` package structs a host +// service or capability uses and returns the deprecated re-export aliases to emit +// for them. +// +// A shared type counts as used when a field references it by qualified name +// (e.g. types.Track) or via a declared alias used by bare name (e.g. a field of +// type TrackInfo where `type TrackInfo = types.Track`). The shared struct's own +// fields are followed transitively so nested shared types are picked up too. For +// every used canonical type, each declared `type X = types.Canonical` alias is +// emitted as a SharedAlias so the generated PDK keeps re-exporting it for +// backwards compatibility. +// +// It returns the deprecated re-export aliases to emit and the resolved shapes of +// every used shared type (alias or not, for schema inlining). +// +// Returns an error if a referenced shared type cannot be found in the shared registry. +func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) { + // Index declared shared aliases by the canonical type they target, e.g. + // "Track" -> [TrackInfo]. A canonical type may have more than one alias. + aliasesByCanonical := map[string][]TypeAlias{} + for _, a := range aliasMap { + if a.IsSharedAlias() { + canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix) + aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a) + } + } + + // Walk the referenced types, following nested shared references inside the + // shared structs, to find the set of canonical shared types used. + used := map[string]bool{} + var queue []string + for name := range referenced { + if c, ok := seedSharedCanonical(name, aliasMap); ok { + queue = append(queue, c) + } + } + fieldRefs := map[string]bool{} + for len(queue) > 0 { + canonical := queue[0] + queue = queue[1:] + if used[canonical] { + continue + } + def, ok := shared[canonical] + if !ok { + return nil, nil, fmt.Errorf( + "shared type %q could not be resolved: pass -shared= pointing at the shared types package, and ensure %s is defined there", + canonical, sharedTypesPrefix+canonical, + ) + } + used[canonical] = true + for _, f := range def.Fields { + clear(fieldRefs) + collectReferencedTypes(f.Type, fieldRefs) + for t := range fieldRefs { + if c, ok := nestedSharedCanonical(t, shared); ok { + queue = append(queue, c) + } + } + } + } + + var out []SharedAlias + var usedDefs []StructDef + for canonical := range used { + usedDefs = append(usedDefs, shared[canonical]) + for _, a := range aliasesByCanonical[canonical] { + out = append(out, SharedAlias{Name: a.Name, Target: a.Type, Doc: a.Doc, Def: shared[canonical]}) + } + } + slices.SortFunc(out, func(a, b SharedAlias) int { return strings.Compare(a.Name, b.Name) }) + slices.SortFunc(usedDefs, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) + return out, usedDefs, nil +} + +// seedSharedCanonical maps a type token referenced by a capability/service field +// to the canonical shared type it denotes. It recognizes qualified references +// (types.X -> X) and declared shared aliases used by bare name (X where +// `type X = types.Y` -> Y). A bare name that is not a declared shared alias is +// not treated as shared, so a local struct sharing a name with a shared type is +// never misclassified. +func seedSharedCanonical(name string, aliasMap map[string]TypeAlias) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if a, ok := aliasMap[name]; ok && a.IsSharedAlias() { + return strings.TrimPrefix(a.Type, sharedTypesPrefix), true + } + return "", false +} + +// nestedSharedCanonical maps a type token found inside a shared struct's own +// fields to a canonical shared type. Within the shared package, types reference +// each other by bare name (e.g. Track.Artists is []ArtistRef), so any bare name +// present in the shared registry counts. +func nestedSharedCanonical(name string, shared map[string]StructDef) (string, bool) { + if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { + return rest, true + } + if _, ok := shared[name]; ok { + return name, true + } + return "", false +} + // collectAllStructDependencies recursively collects all struct types referenced by other structs. func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) { // Keep iterating until no new types are added @@ -301,20 +488,11 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri return export, nil } -// parseFile parses a single Go source file and extracts host services. -func parseFile(fset *token.FileSet, path string) ([]Service, error) { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, err - } - - // First pass: collect all struct definitions in the file - allStructs := parseStructs(f) - structMap := make(map[string]StructDef) - for _, s := range allStructs { - structMap[s.Name] = s - } - +// parseServiceFile parses a single Go source file and extracts host services. +// pkgStructMap and pkgAliasMap are the package-wide struct and alias maps built +// from all files in the package, so a host-service interface can reference types +// defined in a sibling file. +func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { var services []Service for _, decl := range f.Decls { @@ -382,9 +560,22 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) { } } + // Resolve shared-type aliases against the registry. Host-service schemas + // are not generated (the -schemas pass is capability-only), so the resolved + // shared shapes are not needed here. + sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared) + if err != nil { + return nil, err + } + service.SharedAliases = sharedAliases + + // Recursively collect all struct dependencies so types referenced only + // transitively (e.g. a field type of a referenced struct) are attached. + collectAllStructDependencies(referencedTypes, pkgStructMap) + // Attach referenced structs to the service (sorted for stable output) for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { - if s, exists := structMap[typeName]; exists { + if s, exists := pkgStructMap[typeName]; exists { service.Structs = append(service.Structs, s) } } @@ -469,9 +660,10 @@ func parseTypeAliases(f *ast.File) []TypeAlias { docText, _ := getDocComment(genDecl, typeSpec) aliases = append(aliases, TypeAlias{ - Name: typeSpec.Name.Name, - Type: typeToString(typeSpec.Type), - Doc: cleanDoc(docText), + Name: typeSpec.Name.Name, + Type: typeToString(typeSpec.Type), + Doc: cleanDoc(docText), + IsAlias: typeSpec.Assign.IsValid(), }) } } @@ -640,6 +832,14 @@ func collectReferencedTypes(goType string, refs map[string]bool) { return } + // Qualified reference to the shared `types` package (e.g. types.Track). + // These start with a lowercase package selector, so they must be collected + // before the uppercase check below would skip them. + if strings.HasPrefix(goType, sharedTypesPrefix) { + refs[goType] = true + return + } + // Check if it's a custom type (starts with uppercase, not a builtin) if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' { switch goType { diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f43578397..bfdb93db5 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -212,6 +212,136 @@ type RegularInterface interface { Expect(err).NotTo(HaveOccurred()) Expect(services).To(BeEmpty()) }) + + It("should resolve structs defined in a sibling file of the same package", func() { + // types.go defines Track and Artist — no host service here + typesSrc := `package host + +// Artist is a track participant. +type Artist struct { + // ID is the artist identifier. + ID string ` + "`json:\"id\"`" + ` + // Name is the artist name. + Name string ` + "`json:\"name\"`" + ` +} + +// Track is a media file projection. +type Track struct { + // ID is the track identifier. + ID string ` + "`json:\"id\"`" + ` + // Title is the track title. + Title string ` + "`json:\"title\"`" + ` + // Participants maps role to artists (transitive dependency test). + Participants map[string][]Artist ` + "`json:\"participants\"`" + ` +} +` + // service.go defines the host service that references Track from types.go + serviceSrc := `package host + +import "context" + +// MatcherService matches tracks. +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchTrack finds a matching track. + //nd:hostfunc + MatchTrack(ctx context.Context, t Track) (matched bool, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(typesSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(serviceSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + svc := services[0] + Expect(svc.Name).To(Equal("Matcher")) + Expect(svc.Methods).To(HaveLen(1)) + Expect(svc.Methods[0].Name).To(Equal("MatchTrack")) + + // Track must be resolved from the sibling file, not just the service file. + // Artist must also be transitively resolved (Track.Participants references it). + structNames := make([]string, len(svc.Structs)) + for i, s := range svc.Structs { + structNames[i] = s.Name + } + Expect(structNames).To(ConsistOf("Track", "Artist")) + }) + + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +type TrackInfo = types.TrackInfo +` + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + Match(ctx context.Context, t TrackInfo) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + _, err := ParseDirectoryWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases declared in a sibling file (package-wide alias map)", func() { + // File A: declares the shared-type alias in the same package + fileA := `package host + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type Track = types.Track +` + // File B: declares the host service that references Track from file A + fileB := `package host + +import "context" + +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + //nd:hostfunc + MatchSongs(ctx context.Context, query string) (results []Track, err error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "Track": { + Name: "Track", + Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artist", Type: "string", JSONTag: "artist"}, + }, + }, + } + + services, err := ParseDirectoryWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range services[0].SharedAliases { + byName[a.Name] = a + } + // Track alias is in a sibling file — must be resolved package-wide + Expect(byName).To(HaveKey("Track")) + Expect(byName["Track"].Target).To(Equal("types.Track")) + }) }) Describe("parseKeyValuePairs", func() { @@ -520,6 +650,182 @@ type Output struct { Expect(capabilities[0].Methods).To(HaveLen(1)) Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod")) }) + + It("distinguishes Go type aliases from defined types", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// ScrobblerError is a sentinel error string. +type ScrobblerError string + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_check + Check(ArtistRef) (bool, error) +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + } + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + // ArtistRef is a shared-type alias (types.*): it lands in SharedAliases, not TypeAliases. + sharedByName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + sharedByName[a.Name] = a + } + Expect(sharedByName).To(HaveKey("ArtistRef")) + Expect(sharedByName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // ScrobblerError is a plain defined type: it stays in TypeAliases. + typeByName := map[string]TypeAlias{} + for _, a := range caps[0].TypeAliases { + typeByName[a.Name] = a + } + Expect(typeByName).To(HaveKey("ScrobblerError")) + Expect(typeByName["ScrobblerError"].IsAlias).To(BeFalse()) + }) + }) + + Describe("ParseCapabilitiesWithShared", func() { + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// TrackInfo is an alias for the shared type. +type TrackInfo = types.TrackInfo + +//nd:capability name=nowplaying required=true +type NowPlaying interface { + //nd:export name=nd_now_playing + NowPlaying(TrackInfo) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "nowplaying.go"), []byte(src), 0600)).To(Succeed()) + + _, err := ParseCapabilitiesWithShared(tmpDir, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TrackInfo")) + Expect(err.Error()).To(ContainSubstring("-shared")) + }) + + It("resolves shared-type aliases against the registry", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "TrackInfo": {Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.TrackInfo. +type TrackInfo = types.TrackInfo + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track TrackInfo ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + names := []string{} + for _, a := range caps[0].SharedAliases { + names = append(names, a.Name) + } + // TrackInfo is referenced directly; ArtistRef is pulled in transitively via TrackInfo.Artists. + Expect(names).To(ContainElements("TrackInfo", "ArtistRef")) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + Expect(byName["TrackInfo"].Target).To(Equal("types.TrackInfo")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + }) + + It("resolves shared types from qualified types.X references with a renamed alias", func() { + shared := map[string]StructDef{ + "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + "Track": {Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}, + {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, + }}, + } + src := `package capabilities + +import "github.com/navidrome/navidrome/plugins/types" + +// Deprecated: use types.Track. +type TrackInfo = types.Track + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// NowPlayingRequest carries a track. +type NowPlayingRequest struct { + Track types.Track ` + "`json:\"track\"`" + ` +} + +//nd:capability name=scrobbler required=true +type Scrobbler interface { + //nd:export name=nd_scrobbler_now_playing + NowPlaying(NowPlayingRequest) error +} +` + Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) + + caps, err := ParseCapabilitiesWithShared(tmpDir, shared) + Expect(err).NotTo(HaveOccurred()) + Expect(caps).To(HaveLen(1)) + + byName := map[string]SharedAlias{} + for _, a := range caps[0].SharedAliases { + byName[a.Name] = a + } + // The deprecated alias keeps its name (TrackInfo) but now targets types.Track. + // ArtistRef is pulled in transitively via Track.Artists. + Expect(byName).To(HaveKey("TrackInfo")) + Expect(byName).To(HaveKey("ArtistRef")) + Expect(byName["TrackInfo"].Target).To(Equal("types.Track")) + Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining + Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) + + // The capability struct field keeps the canonical qualified reference. + var nowPlaying StructDef + for _, st := range caps[0].Structs { + if st.Name == "NowPlayingRequest" { + nowPlaying = st + } + } + Expect(nowPlaying.Fields).To(HaveLen(1)) + Expect(nowPlaying.Fields[0].Type).To(Equal("types.Track")) + }) }) Describe("Export helpers", func() { diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index ebcd80739..8b88f367c 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -8,9 +8,21 @@ package {{.Package}} import ( +{{- if .Capability.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 790ed93e4..2ee7ece1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -2,7 +2,7 @@ // // This file contains export wrappers for the {{.Capability.Interface}} capability. // It is intended for use in Navidrome plugins built with extism-pdk. -{{if .Capability.Structs}} +{{if or .Capability.Structs .Capability.SharedAliases}} use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; @@ -24,6 +24,13 @@ fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } {{- end}} +{{- /* Generate deprecated aliases to the shared types crate */ -}} +{{- range .Capability.SharedAliases}} + +#[deprecated(note = "use {{rustSharedNote .Target}}")] +pub type {{.Name}} = {{rustSharedTarget .Target}}; +{{- end}} + {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} @@ -131,9 +138,9 @@ macro_rules! register_{{snakeCase .Package}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; @@ -178,9 +185,9 @@ macro_rules! {{registerMacroName .Name}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> + req: extism_pdk::Json<{{rustMethodType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{if isPrimitiveRust .Output.Type}}{{rustOutputType .Output.Type}}{{else}}$crate::{{snakeCase $.Package}}::{{rustOutputType .Output.Type}}{{end}}>{{else}}(){{end}}> { + ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl index 90f72be93..59a886f08 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -7,6 +7,19 @@ //go:build !wasip1 package {{.Package}} +{{- if .Capability.ImportsSharedTypes}} + +import "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Capability.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate type alias definitions */ -}} {{- range .Capability.TypeAliases}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee04446..b49680d1d 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -12,10 +12,23 @@ import ( {{- if .Service.HasErrors}} "errors" {{- end}} +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} + {{- /* Generate struct definitions */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl deleted file mode 100644 index 7ccaa6106..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ /dev/null @@ -1,111 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the {{.Service.Name}} host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -{{- if .Service.HasByteFields}} -import base64 -{{- end}} - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - -{{- /* Generate raw host function imports */ -}} -{{range .Service.Methods}} - - -@extism.import_fn("extism:host/user", "{{exportName .}}") -def _{{exportName .}}(offset: int) -> int: - """Raw host function - do not call directly.""" - ... -{{- end}} -{{- /* Generate dataclasses for multi-value returns */ -}} -{{range .Service.Methods}} -{{- if .NeedsResultClass}} - - -@dataclass -class {{pythonResultType .}}: - """Result type for {{pythonFunc .}}.""" -{{- range .Returns}} - {{.PythonName}}: {{.PythonType}} -{{- end}} -{{- end}} -{{- end}} -{{- /* Generate wrapper functions */ -}} -{{range .Service.Methods}} - - -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: - """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} -{{- if .HasParams}} - - Args: -{{- range .Params}} - {{.PythonName}}: {{.PythonType}} parameter. -{{- end}} -{{- end}} -{{- if .HasReturns}} - - Returns: -{{- if .NeedsResultClass}} - {{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}. -{{- else}} - {{(index .Returns 0).PythonType}}: The result value. -{{- end}} -{{- end}} - - Raises: - HostFunctionError: If the host function returns an error. - """ -{{- if .HasParams}} - request = { -{{- range .Params}} -{{- if .IsByteSlice}} - "{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"), -{{- else}} - "{{.JSONName}}": {{.PythonName}}, -{{- end}} -{{- end}} - } - request_bytes = json.dumps(request).encode("utf-8") -{{- else}} - request_bytes = b"{}" -{{- end}} - request_mem = extism.memory.alloc(request_bytes) - response_offset = _{{exportName .}}(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) -{{if .HasError}} - if response.get("error"): - raise HostFunctionError(response["error"]) -{{end}} -{{- if .NeedsResultClass}} - return {{pythonResultType .}}( -{{- range .Returns}} -{{- if .IsByteSlice}} - {{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")), -{{- else}} - {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}), -{{- end}} -{{- end}} - ) -{{- else if .HasReturns}} -{{- if (index .Returns 0).IsByteSlice}} - return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", "")) -{{- else}} - return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) -{{- end}} -{{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index da19df666..d2a8f3f27 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -8,7 +8,21 @@ package {{.Package}} -import "github.com/stretchr/testify/mock" +import ( +{{- if .Service.ImportsSharedTypes}} + "github.com/navidrome/navidrome/plugins/pdk/go/types" +{{- end}} + "github.com/stretchr/testify/mock" +) + +{{- /* Generate deprecated shared-type aliases */ -}} +{{- range .Service.SharedAliases}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- end}} +type {{.Name}} = {{.Target}} +{{- end}} {{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}} {{- range .Service.Structs}} diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index 083f7577e..b1ac4dd36 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -7,6 +7,10 @@ import ( "encoding/json" extism "github.com/extism/go-sdk" +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/types" +{{- end}} ) {{- /* Generate request/response types for all methods */ -}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl new file mode 100644 index 000000000..698429b96 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl @@ -0,0 +1,23 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package {{.Package}} holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package {{.Package}} +{{- range .Structs}} + +{{- if .Doc}} +{{formatDoc .Doc}} +{{- else}} +// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +type {{.Name}} struct { +{{- range .Fields}} +{{- if .Doc}} +{{formatDoc .Doc | indent 1}} +{{- end}} + {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl new file mode 100644 index 000000000..b7bc131ff --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl @@ -0,0 +1,49 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; +{{- if .HasHashMap}} +use std::collections::HashMap; +{{- end}} +{{- if .HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +{{- range .Structs}} + +{{- if .Doc}} +{{rustDocComment .Doc}} +{{- else}} +/// {{.Name}} represents the {{.Name}} data structure. +{{- end}} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct {{.Name}} { +{{- range .Fields}} +{{- if .Doc}} +{{rustDocComment .Doc | indent 4}} +{{- end}} +{{- if .OmitEmpty}} + #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] +{{- else}} + #[serde(default)] +{{- end}} +{{- if .IsByteSlice}} + #[serde(with = "base64_bytes")] +{{- end}} + pub {{rustFieldName .Name}}: {{fieldRustType .}}, +{{- end}} +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 6132dfbc4..09657d09e 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -5,34 +5,59 @@ import ( "unicode" ) +// sharedTypesPrefix is the package selector a capability or host-service source +// uses to reference the shared types package (e.g. types.Track). +const sharedTypesPrefix = "types." + // Service represents a parsed host service interface. type Service struct { - Name string // Service name from annotation (e.g., "SubsonicAPI") - Permission string // Manifest permission key (e.g., "subsonicapi") - Interface string // Go interface name (e.g., "SubsonicAPIService") - Methods []Method // Methods marked with //nd:hostfunc - Doc string // Documentation comment for the service - Structs []StructDef // Structs used by this service + Name string // Service name from annotation (e.g., "SubsonicAPI") + Permission string // Manifest permission key (e.g., "subsonicapi") + Interface string // Go interface name (e.g., "SubsonicAPIService") + Methods []Method // Methods marked with //nd:hostfunc + Doc string // Documentation comment for the service + Structs []StructDef // Structs used by this service + SharedAliases []SharedAlias // Aliases to types in the shared `types` package } // Capability represents a parsed capability interface for plugin exports. type Capability struct { - Name string // Package name from annotation (e.g., "metadata") - Interface string // Go interface name (e.g., "MetadataAgent") - Required bool // If true, all methods must be implemented - Methods []Export // Methods marked with //nd:export - Doc string // Documentation comment for the capability - Structs []StructDef // Structs used by this capability - TypeAliases []TypeAlias // Type aliases used by this capability - Consts []ConstGroup // Const groups used by this capability - SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + Name string // Package name from annotation (e.g., "metadata") + Interface string // Go interface name (e.g., "MetadataAgent") + Required bool // If true, all methods must be implemented + Methods []Export // Methods marked with //nd:export + Doc string // Documentation comment for the capability + Structs []StructDef // Structs used by this capability + TypeAliases []TypeAlias // Type aliases used by this capability + Consts []ConstGroup // Const groups used by this capability + SourceFile string // Base name of source file without extension (e.g., "websocket_callback") + SharedAliases []SharedAlias // Aliases to types in the shared `types` package + SharedTypes []StructDef // Resolved shapes of every used shared type, keyed by canonical name (for schema inlining, alias or not) } -// TypeAlias represents a type alias definition (e.g., type ScrobblerErrorType string). +// TypeAlias represents a type declaration (e.g. type ScrobblerErrorType string) +// or a Go type alias (e.g. type TrackInfo = types.Track). type TypeAlias struct { - Name string // Type name - Type string // Underlying type - Doc string // Documentation comment + Name string // Type name + Type string // Underlying type (or alias target, e.g. "types.Track") + Doc string // Documentation comment + IsAlias bool // true for `type X = Y` (alias); false for `type X Y` (defined type) +} + +// IsDeprecated reports whether the alias carries a `Deprecated:` doc line. +func (t TypeAlias) IsDeprecated() bool { + for _, line := range strings.Split(t.Doc, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "Deprecated:") { + return true + } + } + return false +} + +// IsSharedAlias reports whether this alias targets the shared types package +// (e.g. `type TrackInfo = types.Track`). +func (t TypeAlias) IsSharedAlias() bool { + return t.IsAlias && strings.HasPrefix(t.Type, sharedTypesPrefix) } // ConstGroup represents a group of const definitions. @@ -48,12 +73,90 @@ type ConstDef struct { Doc string // Documentation comment } -// KnownStructs returns a map of struct names defined in this capability. +// SharedAlias is a deprecated alias from a capability/host package to a type in +// the shared `types` package (e.g. type TrackInfo = types.Track). Def is the +// resolved shared struct, kept for XTP schema inlining. +type SharedAlias struct { + Name string // local name, e.g. "TrackInfo" + Target string // alias target, e.g. "types.Track" + Doc string // doc comment (carries the Deprecated: line) + Def StructDef // resolved shared struct shape +} + +// ImportsSharedTypes reports whether this capability references the shared types package. +// A reference can come from a deprecated re-export alias (e.g. type SongRef = types.SongRef) +// or directly from the canonical form (e.g. types.SongRef) in a struct field or a method +// signature, so the generated Go import must be emitted even when no alias is declared. +func (c Capability) ImportsSharedTypes() bool { + if len(c.SharedAliases) > 0 || structsReferenceSharedTypes(c.Structs) { + return true + } + for _, m := range c.Methods { + if typeReferencesSharedTypes(m.Input.Type) || typeReferencesSharedTypes(m.Output.Type) { + return true + } + } + return false +} + +// ImportsSharedTypes reports whether this service references the shared types package. +func (s Service) ImportsSharedTypes() bool { + if len(s.SharedAliases) > 0 || structsReferenceSharedTypes(s.Structs) { + return true + } + for _, m := range s.Methods { + for _, p := range m.Params { + if typeReferencesSharedTypes(p.Type) { + return true + } + } + for _, r := range m.Returns { + if typeReferencesSharedTypes(r.Type) { + return true + } + } + } + return false +} + +// structsReferenceSharedTypes reports whether any field across the given structs +// refers to the shared types package by its qualified name (e.g. types.SongRef, +// []types.SongRef, map[string]types.SongRef). +func structsReferenceSharedTypes(structs []StructDef) bool { + for _, st := range structs { + for _, f := range st.Fields { + if typeReferencesSharedTypes(f.Type) { + return true + } + } + } + return false +} + +// typeReferencesSharedTypes reports whether a Go type expression refers to the +// shared types package by its qualified name, accounting for pointer, slice, and +// map wrappers (e.g. types.SongRef, []types.SongRef, map[string]types.SongRef). +func typeReferencesSharedTypes(goType string) bool { + refs := map[string]bool{} + collectReferencedTypes(goType, refs) + for t := range refs { + if strings.HasPrefix(t, sharedTypesPrefix) { + return true + } + } + return false +} + +// KnownStructs returns a map of struct names defined in this capability, +// including shared-alias names so Rust field-type resolution finds them. func (c Capability) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range c.Structs { result[st.Name] = true } + for _, sa := range c.SharedAliases { + result[sa.Name] = true + } return result } @@ -154,12 +257,16 @@ func (s Service) ExportPrefix() string { return strings.ToLower(s.Name) } -// KnownStructs returns a map of struct names defined in this service. +// KnownStructs returns a map of struct names defined in this service, +// including shared-alias names so Rust field-type resolution finds them. func (s Service) KnownStructs() map[string]bool { result := make(map[string]bool) for _, st := range s.Structs { result[st.Name] = true } + for _, sa := range s.SharedAliases { + result[sa.Name] = true + } return result } @@ -427,24 +534,6 @@ func toJSONName(name string) string { return string(result) } -// ToPythonType converts a Go type to its Python equivalent. -func ToPythonType(goType string) string { - switch goType { - case "string": - return "str" - case "int", "int32", "int64": - return "int" - case "float32", "float64": - return "float" - case "bool": - return "bool" - case "[]byte": - return "bytes" - default: - return "Any" - } -} - // ToSnakeCase converts a PascalCase or camelCase string to snake_case. // It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id"). func ToSnakeCase(s string) string { @@ -469,31 +558,6 @@ func ToSnakeCase(s string) string { return strings.ToLower(result.String()) } -// PythonFunctionName returns the Python function name for a method. -func (m Method) PythonFunctionName(servicePrefix string) string { - return ToSnakeCase(servicePrefix + m.Name) -} - -// PythonResultTypeName returns the Python dataclass name for multi-value returns. -func (m Method) PythonResultTypeName(serviceName string) string { - return serviceName + m.Name + "Result" -} - -// NeedsResultClass returns true if the method needs a dataclass for returns. -func (m Method) NeedsResultClass() bool { - return len(m.Returns) > 1 -} - -// PythonType returns the Python type for this parameter. -func (p Param) PythonType() string { - return ToPythonType(p.Type) -} - -// PythonName returns the snake_case Python name for this parameter. -func (p Param) PythonName() string { - return ToSnakeCase(p.Name) -} - // ToRustType converts a Go type to its Rust equivalent. func ToRustType(goType string) string { return ToRustTypeWithStructs(goType, nil) @@ -562,6 +626,13 @@ func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustTypeWithShared returns the Rust type, resolving shared-alias names to their +// canonical nd_pdk_types::X crate path (e.g. a return of []Track where +// type Track = types.Track renders as Vec). +func (p Param) RustTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustParamType returns the Rust type for this parameter when used as a function argument. func (p Param) RustParamType() string { return RustParamType(p.Type) @@ -575,6 +646,15 @@ func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string { return ToRustTypeWithStructs(p.Type, knownStructs) } +// RustParamTypeWithShared returns the Rust param type, resolving shared-alias +// names to their canonical nd_pdk_types::X crate path. +func (p Param) RustParamTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { + if p.Type == "string" { + return "&str" + } + return ToRustTypeWithShared(p.Type, knownStructs, shared) +} + // RustName returns the snake_case Rust name for this parameter. func (p Param) RustName() string { return ToSnakeCase(p.Name) @@ -604,9 +684,18 @@ func (f FieldDef) NeedsDefault() bool { // ToRustTypeWithStructs converts a Go type to its Rust equivalent, // using known struct names instead of serde_json::Value. func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { + return toRustType(goType, knownStructs, nil) +} + +// ToRustTypeWithShared resolves shared-alias names to their canonical nd_pdk_types::X path. +func ToRustTypeWithShared(goType string, knownStructs map[string]bool, shared map[string]string) string { + return toRustType(goType, knownStructs, shared) +} + +func toRustType(goType string, knownStructs map[string]bool, shared map[string]string) string { // Handle pointer types if strings.HasPrefix(goType, "*") { - inner := ToRustTypeWithStructs(goType[1:], knownStructs) + inner := toRustType(goType[1:], knownStructs, shared) return "Option<" + inner + ">" } // Handle slice types @@ -614,7 +703,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { if goType == "[]byte" { return "Vec" } - inner := ToRustTypeWithStructs(goType[2:], knownStructs) + inner := toRustType(goType[2:], knownStructs, shared) return "Vec<" + inner + ">" } // Handle map types @@ -636,7 +725,7 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { } keyType := rest[:keyEnd] valueType := rest[keyEnd+1:] - return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">" + return "std::collections::HashMap<" + toRustType(keyType, knownStructs, shared) + ", " + toRustType(valueType, knownStructs, shared) + ">" } switch goType { @@ -659,6 +748,17 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { case "interface{}", "any": return "serde_json::Value" default: + // Qualified reference to the shared types crate (e.g. types.Track -> + // nd_pdk_types::Track). + if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { + return "nd_pdk_types::" + rest + } + // Resolve shared-alias names to their canonical nd_pdk_types:: path. + if shared != nil { + if t, ok := shared[goType]; ok { + return t + } + } // Check if this is a known struct type if knownStructs != nil && knownStructs[goType] { return goType diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index cc2a7d0e0..20c067ffa 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -60,16 +60,18 @@ type ( func GenerateSchema(cap Capability) ([]byte, error) { schema := xtpSchema{Version: "v1-draft"} + aliasToCanonical := buildAliasToCanonical(cap) + // Build exports as ordered map if len(cap.Methods) > 0 { schema.Exports = yaml.Node{Kind: yaml.MappingNode} for _, export := range cap.Methods { - addToMap(&schema.Exports, export.ExportName, buildExport(export)) + addToMap(&schema.Exports, export.ExportName, buildExport(export, aliasToCanonical)) } } // Build components/schemas - schemas := buildSchemas(cap) + schemas := buildSchemas(cap, aliasToCanonical) if len(schemas.Content) > 0 { schema.Components = &xtpComponents{Schemas: schemas} } @@ -77,11 +79,22 @@ func GenerateSchema(cap Capability) ([]byte, error) { return yaml.Marshal(schema) } -func buildExport(export Export) xtpExport { +// buildAliasToCanonical maps each deprecated shared-alias name to the canonical +// shared type it targets (e.g. TrackInfo -> Track). Schema components are emitted +// under the canonical name, so every $ref site must resolve through this map. +func buildAliasToCanonical(cap Capability) map[string]string { + m := map[string]string{} + for _, a := range cap.SharedAliases { + m[a.Name] = strings.TrimPrefix(a.Target, sharedTypesPrefix) + } + return m +} + +func buildExport(export Export, aliasToCanonical map[string]string) xtpExport { e := xtpExport{Description: cleanDocForYAML(export.Doc)} if export.Input.Type != "" { e.Input = &xtpIOParam{ - Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"), + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(export.Input.Type), aliasToCanonical), ContentType: "application/json", } } @@ -95,7 +108,7 @@ func buildExport(export Export) xtpExport { } } else { e.Output = &xtpIOParam{ - Ref: "#/components/schemas/" + outputType, + Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(outputType), aliasToCanonical), ContentType: "application/json", } } @@ -112,15 +125,38 @@ func isPrimitiveGoType(goType string) bool { return false } -func buildSchemas(cap Capability) yaml.Node { +func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node { schemas := yaml.Node{Kind: yaml.MappingNode} knownTypes := cap.KnownStructs() for _, alias := range cap.TypeAliases { knownTypes[alias.Name] = true } + // Register shared types under their canonical name (e.g. types.Track -> Track) + // and stash their struct shapes for inlining. SharedTypes covers every used + // shared type, including ones referenced directly as types.X with no declared + // deprecated alias; SharedAliases is folded in for completeness. + sharedDefs := map[string]StructDef{} + for _, def := range cap.SharedTypes { + knownTypes[def.Name] = true + sharedDefs[def.Name] = def + } + for _, a := range cap.SharedAliases { + canonical := strings.TrimPrefix(a.Target, sharedTypesPrefix) + knownTypes[canonical] = true + sharedDefs[canonical] = a.Def + } + // Collect types that are actually used by exports - usedTypes := collectUsedTypes(cap, knownTypes) + usedTypes := collectUsedTypes(cap, knownTypes, sharedDefs) + + // A used alias name (e.g. TrackInfo) implies its canonical component (Track) is + // used, since the alias-typed field's $ref resolves to the canonical name. + for alias, canonical := range aliasToCanonical { + if usedTypes[alias] { + usedTypes[canonical] = true + } + } // Sort structs by name for consistent output structNames := make([]string, 0, len(cap.Structs)) @@ -135,7 +171,19 @@ func buildSchemas(cap Capability) yaml.Node { for _, name := range structNames { st := structMap[name] - addToMap(&schemas, name, buildObjectSchema(st, knownTypes)) + addToMap(&schemas, name, buildObjectSchema(st, knownTypes, aliasToCanonical)) + } + + // Emit components for used shared aliases (sorted for deterministic output). + sharedNames := make([]string, 0, len(sharedDefs)) + for name, def := range sharedDefs { + if usedTypes[name] && len(def.Fields) > 0 { + sharedNames = append(sharedNames, name) + } + } + sort.Strings(sharedNames) + for _, name := range sharedNames { + addToMap(&schemas, name, buildObjectSchema(sharedDefs[name], knownTypes, aliasToCanonical)) } // Build enum types from type aliases (only if used by exports) @@ -157,18 +205,18 @@ func buildSchemas(cap Capability) yaml.Node { } // collectUsedTypes returns a set of type names that are reachable from exports. -func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]bool { +func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef) map[string]bool { used := make(map[string]bool) // Start with types directly referenced by exports for _, export := range cap.Methods { if export.Input.Type != "" { - addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used) + addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, sharedDefs, used) } if export.Output.Type != "" { outputType := strings.TrimPrefix(export.Output.Type, "*") if !isPrimitiveGoType(outputType) { - addTypeAndDeps(outputType, cap, knownTypes, used) + addTypeAndDeps(outputType, cap, knownTypes, sharedDefs, used) } } } @@ -177,28 +225,55 @@ func collectUsedTypes(cap Capability, knownTypes map[string]bool) map[string]boo } // addTypeAndDeps adds a type and all its dependencies to the used set. -func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) { +func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef, used map[string]bool) { + typeName = strings.TrimPrefix(typeName, sharedTypesPrefix) if used[typeName] || !knownTypes[typeName] { return } used[typeName] = true - // Find the struct and add its field types + // Walk fields of capability-local structs. for _, st := range cap.Structs { if st.Name == typeName { for _, field := range st.Fields { - fieldType := strings.TrimPrefix(field.Type, "*") - fieldType = strings.TrimPrefix(fieldType, "[]") - if knownTypes[fieldType] { - addTypeAndDeps(fieldType, cap, knownTypes, used) + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) } } return } } + + // Walk fields of shared structs so their nested refs are also marked used. + if def, ok := sharedDefs[typeName]; ok { + for _, field := range def.Fields { + if base := fieldBaseType(field.Type); knownTypes[base] { + addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) + } + } + } } -func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema { +// fieldBaseType reduces a field type to the base named type used for schema +// lookups: it strips a leading pointer/slice and any shared `types.` selector. +func fieldBaseType(goType string) string { + goType = strings.TrimPrefix(goType, "*") + goType = strings.TrimPrefix(goType, "[]") + return strings.TrimPrefix(goType, sharedTypesPrefix) +} + +// canonicalRefName resolves a deprecated shared-alias name to the canonical type +// the schema component is emitted under (e.g. TrackInfo -> Track). Non-alias +// names pass through unchanged, so $ref targets always point at an emitted +// component instead of a dangling alias name. +func canonicalRefName(name string, aliasToCanonical map[string]string) string { + if canonical, ok := aliasToCanonical[name]; ok { + return canonical + } + return name +} + +func buildObjectSchema(st StructDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpObjectSchema { schema := xtpObjectSchema{ Description: cleanDocForYAML(st.Doc), Properties: yaml.Node{Kind: yaml.MappingNode}, @@ -206,7 +281,7 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema for _, field := range st.Fields { propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) + addToMap(&schema.Properties, propName, buildProperty(field, knownTypes, aliasToCanonical)) if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { schema.Required = append(schema.Required, propName) @@ -228,7 +303,7 @@ func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema { } } -func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { +func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpProperty { goType := field.Type isPointer := strings.HasPrefix(goType, "*") if isPointer { @@ -240,9 +315,10 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { Nullable: isPointer, } - // Handle reference types (use $ref instead of type) - if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") { - prop.Ref = "#/components/schemas/" + goType + // Handle reference types (use $ref instead of type). Qualified shared + // references (types.X) are referenced by their canonical name. + if refType := strings.TrimPrefix(goType, sharedTypesPrefix); isKnownType(refType, knownTypes) && !strings.HasPrefix(goType, "[]") { + prop.Ref = "#/components/schemas/" + canonicalRefName(refType, aliasToCanonical) return prop } @@ -254,11 +330,11 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { // Handle slice types if strings.HasPrefix(goType, "[]") { - elemType := goType[2:] + elemType := strings.TrimPrefix(goType[2:], sharedTypesPrefix) prop.Type = "array" prop.Items = &xtpProperty{} if isKnownType(elemType, knownTypes) { - prop.Items.Ref = "#/components/schemas/" + elemType + prop.Items.Ref = "#/components/schemas/" + canonicalRefName(elemType, aliasToCanonical) } else { prop.Items.Type = goTypeToXTPType(elemType) } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index 2e28a75d8..f8702aa4d 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -700,6 +700,115 @@ var _ = Describe("XTP Schema Generation", func() { }) }) + Describe("GenerateSchema with shared aliases", func() { + It("inlines shared-alias shapes as schema components", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.TrackInfo", + Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + Expect(string(out)).To(ContainSubstring("TrackInfo:")) + Expect(string(out)).To(ContainSubstring("title:")) + }) + + It("names the shared component by its canonical type for qualified types.X fields", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Component is named by the canonical type (Track), not the deprecated alias. + Expect(s).To(ContainSubstring("Track:")) + Expect(s).NotTo(ContainSubstring("TrackInfo:")) + // The field $ref points at the canonical component. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).To(ContainSubstring("title:")) + }) + + It("points an alias-named field at the canonical component for a renamed alias", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "PlayRequest"}}}, + Structs: []StructDef{{Name: "PlayRequest", Fields: []FieldDef{ + // Field is typed with the deprecated alias name, not the canonical types.Track. + {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // The component is emitted under the canonical name, and the field $ref must + // point at it — not at a non-existent TrackInfo component (dangling reference). + Expect(s).To(ContainSubstring("Track:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("points an alias-named export input/output at the canonical component", func() { + cap := Capability{ + Name: "demo", Interface: "Demo", Required: true, + // The method takes/returns the deprecated alias name directly. + Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", + Input: Param{Name: "input", Type: "TrackInfo"}, + Output: Param{Name: "output", Type: "TrackInfo"}}}, + SharedAliases: []SharedAlias{{ + Name: "TrackInfo", Target: "types.Track", + Def: StructDef{Name: "Track", Fields: []FieldDef{ + {Name: "Title", Type: "string", JSONTag: "title"}}}, + }}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + // Export $ref must resolve to the canonical component, not a missing TrackInfo. + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) + Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) + }) + + It("inlines a directly-referenced shared type that has no deprecated alias", func() { + cap := Capability{ + Name: "scrobbler", Interface: "Scrobbler", Required: true, + Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", + Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, + Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ + {Name: "Song", Type: "types.SongRef", JSONTag: "song"}}}}, + // No SharedAliases: the field references the canonical type directly. + SharedTypes: []StructDef{{Name: "SongRef", Fields: []FieldDef{ + {Name: "Name", Type: "string", JSONTag: "name"}}}}, + } + out, err := GenerateSchema(cap) + Expect(err).NotTo(HaveOccurred()) + s := string(out) + Expect(s).To(ContainSubstring("SongRef:")) + Expect(s).To(ContainSubstring("$ref: '#/components/schemas/SongRef'")) + Expect(s).To(ContainSubstring("name:")) + }) + }) + Describe("GenerateSchema enum filtering", func() { It("should only include enums that are actually used by exports", func() { capability := Capability{ diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go index b34ee4296..6520a3ecd 100644 --- a/plugins/cmd/ndpgen/main.go +++ b/plugins/cmd/ndpgen/main.go @@ -19,7 +19,7 @@ // // Output directories: // - Host wrappers: $input/_gen.go (server-side, used by Navidrome) -// - Host functions: $output/go/host/, $output/python/host/, $output/rust/host/ +// - Host functions: $output/go/host/, $output/rust/host/ // - Capabilities: $output/go// (e.g., $output/go/metadata/) // - Schemas: $input/.yaml (co-located with Go sources) // @@ -32,8 +32,7 @@ // -host-only Generate PDK client wrappers for calling host functions // -capability-only Generate only capability export wrappers // -schemas Generate XTP YAML schemas from capabilities -// -go Generate Go client wrappers (default: true when not using -python/-rust) -// -python Generate Python client wrappers (default: false) +// -go Generate Go client wrappers (default: true when not using -rust) // -rust Generate Rust client wrappers (default: false) // -v Verbose output // -dry-run Preview generated code without writing files @@ -43,8 +42,10 @@ import ( "flag" "fmt" "go/format" + "maps" "os" "path/filepath" + "slices" "strings" "github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal" @@ -55,16 +56,16 @@ type config struct { inputDir string outputDir string // Base output directory (e.g., plugins/pdk) goOutputDir string // Go output: $outputDir/go/host (for host-only) - pythonOutputDir string // Python output: $outputDir/python/host rustOutputDir string // Rust output: $outputDir/rust/host pkgName string hostOnly bool hostWrappers bool // Generate host wrappers (used by Navidrome server) capabilityOnly bool - schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) - pdkOnly bool // Generate PDK abstraction layer wrapper + schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) + pdkOnly bool // Generate PDK abstraction layer wrapper + sharedTypes bool // Generate the shared types package + sharedDir string // Directory of shared types to load as a registry generateGoClient bool - generatePyClient bool generateRsClient bool verbose bool dryRun bool @@ -85,6 +86,14 @@ func main() { return } + if cfg.sharedTypes { + if err := runSharedTypesGeneration(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + if cfg.pdkOnly { if err := runPDKGeneration(cfg); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -157,6 +166,69 @@ func runSchemaGeneration(cfg *config) error { return generateSchemas(cfg, capabilities) } +// writeGenerated creates dir (if needed) and writes content to name inside it. +// In dry-run mode it prints the content instead. +func writeGenerated(dir, name string, content []byte, dryRun, verbose bool) error { + path := filepath.Join(dir, name) + if dryRun { + fmt.Printf("=== %s ===\n%s\n", path, content) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory %s: %w", dir, err) + } + if err := os.WriteFile(path, content, 0600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + if verbose { + fmt.Printf("Generated: %s\n", path) + } + return nil +} + +// runSharedTypesGeneration handles shared types package generation. +func runSharedTypesGeneration(cfg *config) error { + structs, err := internal.LoadSharedTypes(cfg.inputDir) + if err != nil { + return err + } + if len(structs) == 0 { + return nil + } + list := slices.Collect(maps.Values(structs)) + if cfg.generateGoClient { + code, err := internal.GenerateSharedTypesGo(list, "types") + if err != nil { + return fmt.Errorf("generating Go types: %w", err) + } + formatted, err := format.Source(code) + if err != nil { + return fmt.Errorf("formatting Go types: %w\n%s", err, code) + } + dir := filepath.Join(cfg.outputDir, "go", "types") + if err := writeGenerated(dir, "types.go", formatted, cfg.dryRun, cfg.verbose); err != nil { + return err + } + } + if cfg.generateRsClient { + if err := generateSharedTypesRust(list, cfg); err != nil { + return err + } + } + return nil +} + +// generateSharedTypesRust writes the nd-pdk-types crate root to +// /rust/nd-pdk-types/src/lib.rs. +func generateSharedTypesRust(structs []internal.StructDef, cfg *config) error { + code, err := internal.GenerateSharedTypesRust(structs) + if err != nil { + return fmt.Errorf("generating Rust types: %w", err) + } + dir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-types", "src") + return writeGenerated(dir, "lib.rs", code, cfg.dryRun, cfg.verbose) +} + // runPDKGeneration handles PDK abstraction layer code generation. // This generates the pdk wrapper package that wraps extism/go-pdk // with mockable implementations for unit testing on native platforms. @@ -315,8 +387,9 @@ func parseConfig() (*config, error) { capabilityOnly = flag.Bool("capability-only", false, "Generate only capability export wrappers") schemasOnly = flag.Bool("schemas", false, "Generate XTP YAML schemas from capabilities (output to input directory)") pdkOnly = flag.Bool("extism-pdk", false, "Generate PDK abstraction layer by parsing extism/go-pdk") + sharedTypes = flag.Bool("shared-types", false, "Generate the shared types package") + shared = flag.String("shared", "", "Directory of shared types to load as a registry") goClient = flag.Bool("go", false, "Generate Go client wrappers") - pyClient = flag.Bool("python", false, "Generate Python client wrappers") rsClient = flag.Bool("rust", false, "Generate Rust client wrappers") verbose = flag.Bool("v", false, "Verbose output") dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files") @@ -340,6 +413,9 @@ func parseConfig() (*config, error) { if *pdkOnly { modeCount++ } + if *sharedTypes { + modeCount++ + } // Default to host-only if no mode is specified if modeCount == 0 { @@ -348,7 +424,7 @@ func parseConfig() (*config, error) { // Cannot specify multiple modes if modeCount > 1 { - return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -pdk)") + return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -extism-pdk, -shared-types)") } if *outputDir == "" { @@ -372,23 +448,28 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("resolving output path: %w", err) } + absShared := "" + if *shared != "" { + absShared, err = filepath.Abs(*shared) + if err != nil { + return nil, fmt.Errorf("resolving shared path: %w", err) + } + } + // Set output directories for each language // Go host wrappers: $output/go/host/ - // Python host wrappers: $output/python/host/ // Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate) absGoOutput := filepath.Join(absOutput, "go", "host") - absPythonOutput := filepath.Join(absOutput, "python", "host") absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host") // Determine what to generate // Default: generate Go clients if no language flag is specified - anyLangFlag := *goClient || *pyClient || *rsClient + anyLangFlag := *goClient || *rsClient return &config{ inputDir: absInput, outputDir: absOutput, goOutputDir: absGoOutput, - pythonOutputDir: absPythonOutput, rustOutputDir: absRustOutput, pkgName: *pkgName, hostOnly: *hostOnly, @@ -396,8 +477,9 @@ func parseConfig() (*config, error) { capabilityOnly: *capabilityOnly, schemasOnly: *schemasOnly, pdkOnly: *pdkOnly, + sharedTypes: *sharedTypes, + sharedDir: absShared, generateGoClient: *goClient || !anyLangFlag, - generatePyClient: *pyClient, generateRsClient: *rsClient, verbose: *verbose, dryRun: *dryRun, @@ -412,20 +494,21 @@ func parseServices(cfg *config) ([]internal.Service, error) { if cfg.generateGoClient { fmt.Printf("Go output directory: %s\n", cfg.goOutputDir) } - if cfg.generatePyClient { - fmt.Printf("Python output directory: %s\n", cfg.pythonOutputDir) - } if cfg.generateRsClient { fmt.Printf("Rust output directory: %s\n", cfg.rustOutputDir) } fmt.Printf("Package name: %s\n", cfg.pkgName) fmt.Printf("Host-only mode: %v\n", cfg.hostOnly) fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient) - fmt.Printf("Generate Python client code: %v\n", cfg.generatePyClient) fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient) } - services, err := internal.ParseDirectory(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + services, err := internal.ParseDirectoryWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing source files: %w", err) } @@ -455,7 +538,12 @@ func parseCapabilities(cfg *config) ([]internal.Capability, error) { fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly) } - capabilities, err := internal.ParseCapabilities(cfg.inputDir) + shared, err := internal.LoadSharedTypes(cfg.sharedDir) + if err != nil { + return nil, fmt.Errorf("loading shared types: %w", err) + } + + capabilities, err := internal.ParseCapabilitiesWithShared(cfg.inputDir, shared) if err != nil { return nil, fmt.Errorf("parsing capability files: %w", err) } @@ -619,11 +707,6 @@ func generateAllCode(cfg *config, services []internal.Service) error { return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err) } } - if cfg.generatePyClient { - if err := generatePythonClientCode(svc, cfg.pythonOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Python client code for %s: %w", svc.Name, err) - } - } if cfg.generateRsClient { if err := generateRustClientCode(svc, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err) @@ -757,36 +840,6 @@ func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, d return nil } -// generatePythonClientCode generates Python client-side code for a service. -func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateClientPython(svc) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - // Python code goes directly in the output directory - clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".py") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", clientFile, code) - return nil - } - - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating python client directory: %w", err) - } - - if err := os.WriteFile(clientFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Python client code: %s\n", clientFile) - } - return nil -} - // generateRustClientCode generates Rust client-side code for a service. func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { code, err := internal.GenerateClientRust(svc) diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.py b/plugins/cmd/ndpgen/testdata/codec_client_expected.py deleted file mode 100644 index 5142ffd0e..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.py +++ /dev/null @@ -1,53 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Codec host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "codec_encode") -def _codec_encode(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def codec_encode(data: bytes) -> bytes: - """Call the codec_encode host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _codec_encode(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py deleted file mode 100644 index 93370ddcf..000000000 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py +++ /dev/null @@ -1,342 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Comprehensive host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "comprehensive_simpleparams") -def _comprehensive_simpleparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_structparam") -def _comprehensive_structparam(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mixedparams") -def _comprehensive_mixedparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noerror") -def _comprehensive_noerror(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparams") -def _comprehensive_noparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_noparamsnoreturns") -def _comprehensive_noparamsnoreturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_pointerparams") -def _comprehensive_pointerparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_mapparams") -def _comprehensive_mapparams(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_multiplereturns") -def _comprehensive_multiplereturns(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "comprehensive_byteslice") -def _comprehensive_byteslice(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ComprehensiveMultipleReturnsResult: - """Result type for comprehensive_multiple_returns.""" - results: Any - total: int - - -def comprehensive_simple_params(name: str, count: int) -> str: - """Call the comprehensive_simpleparams host function. - - Args: - name: str parameter. - count: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "count": count, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_simpleparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_struct_param(user: Any) -> None: - """Call the comprehensive_structparam host function. - - Args: - user: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_structparam(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_mixed_params(id: str, filter: Any) -> int: - """Call the comprehensive_mixedparams host function. - - Args: - id: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mixedparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) - - -def comprehensive_no_error(name: str) -> str: - """Call the comprehensive_noerror host function. - - Args: - name: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noerror(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def comprehensive_no_params() -> None: - """Call the comprehensive_noparams host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_no_params_no_returns() -> None: - """Call the comprehensive_noparamsnoreturns host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_noparamsnoreturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def comprehensive_pointer_params(id: Any, user: Any) -> Any: - """Call the comprehensive_pointerparams host function. - - Args: - id: Any parameter. - user: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "user": user, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_pointerparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_map_params(data: Any) -> Any: - """Call the comprehensive_mapparams host function. - - Args: - data: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_mapparams(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def comprehensive_multiple_returns(query: str) -> ComprehensiveMultipleReturnsResult: - """Call the comprehensive_multiplereturns host function. - - Args: - query: str parameter. - - Returns: - ComprehensiveMultipleReturnsResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_multiplereturns(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ComprehensiveMultipleReturnsResult( - results=response.get("results", None), - total=response.get("total", 0), - ) - - -def comprehensive_byte_slice(data: bytes) -> bytes: - """Call the comprehensive_byteslice host function. - - Args: - data: bytes parameter. - - Returns: - bytes: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _comprehensive_byteslice(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return base64.b64decode(response.get("result", "")) diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.py b/plugins/cmd/ndpgen/testdata/config_client_expected.py deleted file mode 100644 index 370de6d10..000000000 --- a/plugins/cmd/ndpgen/testdata/config_client_expected.py +++ /dev/null @@ -1,126 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_set") -def _config_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_has") -def _config_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Call the config_get host function. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_set(key: str, value: str) -> None: - """Call the config_set host function. - - Args: - key: str parameter. - value: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def config_has(key: str) -> bool: - """Call the config_has host function. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) diff --git a/plugins/cmd/ndpgen/testdata/counter_client_expected.py b/plugins/cmd/ndpgen/testdata/counter_client_expected.py deleted file mode 100644 index 872d407bb..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_client_expected.py +++ /dev/null @@ -1,49 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Counter host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "counter_count") -def _counter_count(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def counter_count(name: str) -> int: - """Call the counter_count host function. - - Args: - name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _counter_count(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("value", 0) diff --git a/plugins/cmd/ndpgen/testdata/echo_client_expected.py b/plugins/cmd/ndpgen/testdata/echo_client_expected.py deleted file mode 100644 index 06565b0d6..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Echo host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "echo_echo") -def _echo_echo(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def echo_echo(message: str) -> str: - """Call the echo_echo host function. - - Args: - message: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _echo_echo(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("reply", "") diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.py b/plugins/cmd/ndpgen/testdata/list_client_expected.py deleted file mode 100644 index 58ccad146..000000000 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the List host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "list_items") -def _list_items(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def list_items(name: str, filter: Any) -> int: - """Call the list_items host function. - - Args: - name: str parameter. - filter: Any parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _list_items(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("count", 0) diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.py b/plugins/cmd/ndpgen/testdata/math_client_expected.py deleted file mode 100644 index f3ea53335..000000000 --- a/plugins/cmd/ndpgen/testdata/math_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Math host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "math_add") -def _math_add(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def math_add(a: int, b: int) -> int: - """Call the math_add host function. - - Args: - a: int parameter. - b: int parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "a": a, - "b": b, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _math_add(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/cmd/ndpgen/testdata/meta_client_expected.py b/plugins/cmd/ndpgen/testdata/meta_client_expected.py deleted file mode 100644 index 4d20c73ff..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_client_expected.py +++ /dev/null @@ -1,81 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Meta host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "meta_get") -def _meta_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "meta_set") -def _meta_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def meta_get(key: str) -> Any: - """Call the meta_get host function. - - Args: - key: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("value", None) - - -def meta_set(data: Any) -> None: - """Call the meta_set host function. - - Args: - data: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "data": data, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _meta_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/ping_client_expected.py b/plugins/cmd/ndpgen/testdata/ping_client_expected.py deleted file mode 100644 index 4c7d41d8e..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_client_expected.py +++ /dev/null @@ -1,42 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Ping host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "ping_ping") -def _ping_ping(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def ping_ping() -> None: - """Call the ping_ping host function. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _ping_ping(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.py b/plugins/cmd/ndpgen/testdata/search_client_expected.py deleted file mode 100644 index aa2e98a36..000000000 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.py +++ /dev/null @@ -1,62 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Search host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "search_find") -def _search_find(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SearchFindResult: - """Result type for search_find.""" - results: Any - total: int - - -def search_find(query: str) -> SearchFindResult: - """Call the search_find host function. - - Args: - query: str parameter. - - Returns: - SearchFindResult containing results, total,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "query": query, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _search_find(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SearchFindResult( - results=response.get("results", None), - total=response.get("total", 0), - ) diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.py b/plugins/cmd/ndpgen/testdata/store_client_expected.py deleted file mode 100644 index 4a964a497..000000000 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.py +++ /dev/null @@ -1,52 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Store host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "store_save") -def _store_save(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def store_save(item: Any) -> str: - """Call the store_save host function. - - Args: - item: Any parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "item": item, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _store_save(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("id", "") diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.py b/plugins/cmd/ndpgen/testdata/users_client_expected.py deleted file mode 100644 index 468b87b98..000000000 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.py +++ /dev/null @@ -1,54 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_get") -def _users_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get(id: Any, filter: Any) -> Any: - """Call the users_get host function. - - Args: - id: Any parameter. - filter: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "filter": filter, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 12bf9ed3e..10c8f8c66 100644 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ b/plugins/examples/discord-rich-presence-rs/src/lib.rs @@ -18,7 +18,7 @@ use extism_pdk::*; use nd_pdk::host::{artwork, config, scheduler}; use nd_pdk::scrobbler::{ - Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, + Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER, }; use nd_pdk::scheduler::{ @@ -207,6 +207,11 @@ impl Scrobbler for DiscordPlugin { // Discord Rich Presence doesn't need scrobble events - success Ok(()) } + + fn playback_report(&self, _req: PlaybackReportRequest) -> Result<(), ScrobblerError> { + // Discord Rich Presence doesn't need playback reports - success + Ok(()) + } } // ============================================================================ diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index e872d845d..743c03744 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -14,7 +14,7 @@ use extism_pdk::{config, error, http, info, warn, HttpRequest}; use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest, + Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, }; @@ -45,6 +45,15 @@ impl Scrobbler for WebhookPlugin { Ok(()) } + /// Handles playback state reports. This plugin ignores them (webhooks only on scrobble). + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error> { + info!( + "Playback report (ignored): {} - {} for user {} (state: {})", + req.track.artist, req.track.title, req.username, req.state + ); + Ok(()) + } + /// Handles scrobble events by sending HTTP GET requests to configured URLs. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { // Get configured URLs diff --git a/plugins/host/matcher.go b/plugins/host/matcher.go new file mode 100644 index 000000000..678d3bfaa --- /dev/null +++ b/plugins/host/matcher.go @@ -0,0 +1,27 @@ +package host + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + // Username runs the match as that user (case-insensitive): their favourites and + // ratings inform tiebreaking, and the returned tracks carry their annotations. + Username string `json:"username,omitempty"` +} + +// MatcherService resolves externally-obtained songs to local library tracks, +// reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). +// +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchSongs resolves each input song to its best-matching library track. + // It returns one entry per input song, in the same order as the input; the + // entry for an input song that had no match is empty (absent). Results are + // limited to the libraries the plugin (and the scoped user, if any) can access. + //nd:hostfunc + MatchSongs(ctx context.Context, songs []types.SongRef, opts MatchOptions) (results []*types.Track, err error) +} diff --git a/plugins/host/matcher_gen.go b/plugins/host/matcher_gen.go new file mode 100644 index 000000000..1bcd0dc3b --- /dev/null +++ b/plugins/host/matcher_gen.go @@ -0,0 +1,91 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatcherMatchSongsRequest is the request type for Matcher.MatchSongs. +type MatcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +// MatcherMatchSongsResponse is the response type for Matcher.MatchSongs. +type MatcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterMatcherHostFunctions registers Matcher service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterMatcherHostFunctions(service MatcherService) []extism.HostFunction { + return []extism.HostFunction{ + newMatcherMatchSongsHostFunction(service), + } +} + +func newMatcherMatchSongsHostFunction(service MatcherService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "matcher_matchsongs", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + matcherWriteError(p, stack, err) + return + } + var req MatcherMatchSongsRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + matcherWriteError(p, stack, err) + return + } + + // Call the service method + results, svcErr := service.MatchSongs(ctx, req.Songs, req.Opts) + if svcErr != nil { + matcherWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := MatcherMatchSongsResponse{ + Results: results, + } + matcherWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// matcherWriteResponse writes a JSON response to plugin memory. +func matcherWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + matcherWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// matcherWriteError writes an error response to plugin memory. +func matcherWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 151a0d03c..ed8a0e810 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -47,7 +47,7 @@ var _ = Describe("ArtworkService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Initialize auth (required for token generation) diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 0f55bcfda..cf3973fc4 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -343,7 +343,7 @@ var _ = Describe("CacheService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index bd3368a67..b296d29fb 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -57,7 +57,7 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index c3f6ec734..2224b7485 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -54,7 +54,7 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm } // Create plugin data directory - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + 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) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index e5d467f79..997409146 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -34,11 +34,10 @@ var _ = Describe("KVStoreService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing - maxSize := "1KB" - service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) }) @@ -253,8 +252,7 @@ var _ = Describe("KVStoreService", func() { // Close and reopen the service (simulating restart) Expect(service.Close()).To(Succeed()) - maxSize := "1KB" - service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -452,8 +450,7 @@ var _ = Describe("KVStoreService", func() { closeCtx, closeCancel := context.WithCancel(ctx) defer closeCancel() - maxSize := "1KB" - svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) // Insert an expired key so cleanup has work to do @@ -705,9 +702,9 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 5746a3bed..eb5b17a02 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -35,8 +35,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetLibrary", func() { It("should return library metadata without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 1, @@ -67,8 +66,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return library metadata with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 2, @@ -93,8 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return error for non-existent library", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) @@ -107,8 +104,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetAllLibraries", func() { It("should return all libraries without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -130,8 +126,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return all libraries with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -152,10 +147,8 @@ var _ = Describe("LibraryService", Ordered, func() { }) Describe("Library Access Filtering", func() { - It("should only return libraries in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should only return libraries in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -173,10 +166,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results[0].Name).To(Equal("Jazz")) }) - It("should return error when getting a library not in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -192,10 +183,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(err.Error()).To(ContainSubstring("not accessible")) }) - It("should allow access to a library in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should allow access to a library in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -211,10 +200,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(result.Name).To(Equal("Jazz")) }) - It("should return empty list when no libraries are allowed and allLibraries is false", func() { - reason := "test" - // No libraries allowed - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl) + It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -229,10 +216,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results).To(HaveLen(0)) }) - It("should return all libraries when allLibraries is true regardless of allowed list", func() { - reason := "test" - // allLibraries=true should ignore the allowed list - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl) + It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -263,7 +248,7 @@ var _ = Describe("LibraryService", Ordered, func() { // the service registration and configuration without full plugin execution DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) // Create mock &tests.MockLibraryRepo{} mockLibRepo := &tests.MockLibraryRepo{} @@ -357,7 +342,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin and library diff --git a/plugins/host_matcher.go b/plugins/host_matcher.go new file mode 100644 index 000000000..125d16649 --- /dev/null +++ b/plugins/host_matcher.go @@ -0,0 +1,198 @@ +package plugins + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/utils/slice" +) + +type matcherServiceImpl struct { + ds model.DataStore + hasFilesystemPerm bool + users userAccess + libs libraryAccess +} + +func newMatcherService(ds model.DataStore, hasFilesystemPerm bool, users userAccess, libs libraryAccess) host.MatcherService { + return &matcherServiceImpl{ + ds: ds, + hasFilesystemPerm: hasFilesystemPerm, + users: users, + libs: libs, + } +} + +func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongRef, opts host.MatchOptions) ([]*types.Track, error) { + results := make([]*types.Track, len(songs)) + if len(songs) == 0 { + return results, nil + } + + // Fail closed when the plugin has no library scope, rather than matching nothing. + if !s.libs.configured() { + return nil, fmt.Errorf("matcher: no libraries configured for this plugin") + } + + // Set the user context explicitly so the match never inherits the request user + // of whatever invoked the plugin: a username scopes to that user (loading their + // annotations and library access), and an unscoped match runs as admin so only + // the plugin's own library scope constrains the results. + scoped := opts.Username != "" + if scoped { + usr, err := s.users.resolve(ctx, s.ds, opts.Username) + if err != nil { + return nil, fmt.Errorf("matcher: %w", err) + } + ctx = request.WithUser(ctx, *usr) + } else { + ctx = adminContext(ctx) + } + + agentSongs := slice.Map(songs, songRefToAgentSong) + + matched, err := matcher.New(s.ds).MatchSongsIndexed(ctx, agentSongs) + if err != nil { + return nil, err + } + // The plugin's library scope is a second, independent authorization on top of the context + // user's own access, so it's applied here rather than in-query: the unscoped path runs as + // admin (which applyLibraryFilter skips), and folding s.libs into the context user would + // conflate the two scopes instead of intersecting them. + for i, mf := range matched { + // Drop tracks outside the plugin's library scope, leaving that index unmatched. + if !s.libs.contains(mf.LibraryID) { + continue + } + results[i] = s.toTrack(&mf, scoped) + } + return results, nil +} + +// toTrack projects a MediaFile into the public Track DTO. Path needs filesystem +// permission; per-user annotations are only set for a scoped match. +func (s *matcherServiceImpl) toTrack(mf *model.MediaFile, scoped bool) *types.Track { + t := &types.Track{ + ID: mf.ID, + LibraryID: int32(mf.LibraryID), + LibraryName: mf.LibraryName, + Missing: mf.Missing, + Title: mf.Title, + Album: mf.Album, + Artist: mf.Artist, + AlbumArtist: mf.AlbumArtist, + AlbumID: mf.AlbumID, + SortTitle: mf.SortTitle, + SortAlbumName: mf.SortAlbumName, + SortArtistName: mf.SortArtistName, + TrackNumber: int32(mf.TrackNumber), + DiscNumber: int32(mf.DiscNumber), + DiscSubtitle: mf.DiscSubtitle, + Year: int32(mf.Year), + Date: mf.Date, + OriginalYear: int32(mf.OriginalYear), + OriginalDate: mf.OriginalDate, + ReleaseYear: int32(mf.ReleaseYear), + ReleaseDate: mf.ReleaseDate, + Size: mf.Size, + Suffix: mf.Suffix, + Duration: float64(mf.Duration), + BitRate: int32(mf.BitRate), + SampleRate: int32(mf.SampleRate), + BitDepth: ptrInt32(mf.BitDepth), + Channels: int32(mf.Channels), + Codec: mf.Codec, + Comment: mf.Comment, + BPM: ptrInt32(mf.BPM), + ExplicitStatus: mf.ExplicitStatus, + CatalogNum: mf.CatalogNum, + Compilation: mf.Compilation, + HasCoverArt: mf.HasCoverArt, + MbzRecordingID: mf.MbzRecordingID, + MbzReleaseTrackID: mf.MbzReleaseTrackID, + MbzAlbumID: mf.MbzAlbumID, + MbzReleaseGroupID: mf.MbzReleaseGroupID, + MbzAlbumType: mf.MbzAlbumType, + MbzAlbumComment: mf.MbzAlbumComment, + RGAlbumGain: mf.RGAlbumGain, + RGAlbumPeak: mf.RGAlbumPeak, + RGTrackGain: mf.RGTrackGain, + RGTrackPeak: mf.RGTrackPeak, + AverageRating: mf.AverageRating, // aggregate, not user-scoped + BirthTime: unixOrZero(mf.BirthTime), + CreatedAt: unixOrZero(mf.CreatedAt), + UpdatedAt: unixOrZero(mf.UpdatedAt), + } + if s.hasFilesystemPerm { + t.Path = mf.Path + } + if len(mf.Genres) > 0 { + t.Genres = slice.Map(mf.Genres, func(g model.Genre) string { return g.Name }) + } + if len(mf.Tags) > 0 { + t.Tags = make(map[string][]string, len(mf.Tags)) + for name, values := range mf.Tags { + t.Tags[string(name)] = values + } + } + if len(mf.Participants) > 0 { + // Flatten the role→artists map into a role-tagged list, in stable role order. + roles := slices.SortedFunc(maps.Keys(mf.Participants), func(a, b model.Role) int { + return cmp.Compare(a.String(), b.String()) + }) + for _, role := range roles { + for _, p := range mf.Participants[role] { + t.Participants = append(t.Participants, types.ArtistRef{ + ID: p.ID, + Name: p.Name, + MBID: p.MbzArtistID, + SortName: p.SortArtistName, + Role: role.String(), + SubRole: p.SubRole, + }) + } + } + } + if scoped { + t.Starred = mf.Starred + t.StarredAt = unixPtr(mf.StarredAt) + t.Rating = int32(mf.Rating) + t.PlayCount = mf.PlayCount + t.PlayDate = unixPtr(mf.PlayDate) + } + return t +} + +func unixOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +// unixPtr maps a nullable time to Unix seconds, keeping nil distinct from the epoch. +func unixPtr(t *time.Time) *int64 { + if t == nil || t.IsZero() { + return nil + } + return new(t.Unix()) +} + +// ptrInt32 narrows a nullable *int to *int32, keeping nil distinct from a real 0. +func ptrInt32(p *int) *int32 { + if p == nil { + return nil + } + return new(int32(*p)) +} + +var _ host.MatcherService = (*matcherServiceImpl)(nil) diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go new file mode 100644 index 000000000..9d44ee581 --- /dev/null +++ b/plugins/host_matcher_test.go @@ -0,0 +1,490 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MatcherService", Ordered, func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + // newConverter returns the service as its concrete type so converter unit + // tests can call toTrack directly with a chosen filesystem-permission flag. + newConverter := func(hasFilesystemPerm bool) *matcherServiceImpl { + return newMatcherService(nil, hasFilesystemPerm, newUserAccess(nil, true), newLibraryAccess(nil, true)).(*matcherServiceImpl) + } + + Describe("toTrack", func() { + It("projects a MediaFile into a public Track", func() { + bitDepth := 24 + bpm := 128 + rgGain := -7.5 + created := time.Unix(1700000000, 0) + updated := time.Unix(1700000500, 0) + birth := time.Unix(1699999000, 0) + + mf := &model.MediaFile{ + ID: "mf-1", + LibraryID: 3, + LibraryName: "Main", + Path: "/music/song.flac", + Title: "My Song", + Album: "My Album", + Artist: "My Artist", + AlbumArtist: "My Artist", + AlbumID: "al-1", + SortTitle: "my song", + TrackNumber: 4, + DiscNumber: 1, + Year: 2020, + Size: 1234, + Suffix: "flac", + Duration: 210.5, + BitRate: 1000, + SampleRate: 44100, + BitDepth: &bitDepth, + Channels: 2, + Codec: "flac", + Genre: "Rock", + BPM: &bpm, + ExplicitStatus: "c", + Compilation: true, + HasCoverArt: true, + MbzRecordingID: "rec-1", + RGTrackGain: &rgGain, + CreatedAt: created, + UpdatedAt: updated, + BirthTime: birth, + Genres: model.Genres{{Name: "Rock"}, {Name: "Pop"}}, + Tags: model.Tags{model.TagName("isrc"): []string{"US-XXX-00"}}, + } + mf.AverageRating = 4.2 + mf.Participants = model.Participants{} + mf.Participants.Add(model.RoleArtist, model.Artist{ + ID: "ar-1", Name: "My Artist", SortArtistName: "artist, my", MbzArtistID: "mbz-ar-1", + }) + mf.Participants.AddWithSubRole(model.RolePerformer, "violin", model.Artist{ + ID: "ar-2", Name: "A Fiddler", + }) + + track := newConverter(true).toTrack(mf, false) + + Expect(track.ID).To(Equal("mf-1")) + Expect(track.LibraryID).To(Equal(int32(3))) + Expect(track.LibraryName).To(Equal("Main")) + Expect(track.Path).To(Equal("/music/song.flac")) + Expect(track.Title).To(Equal("My Song")) + Expect(track.Duration).To(Equal(210.5)) + Expect(track.BitDepth).To(HaveValue(Equal(int32(24)))) + Expect(track.BPM).To(HaveValue(Equal(int32(128)))) + Expect(track.RGTrackGain).To(HaveValue(Equal(-7.5))) + Expect(track.Compilation).To(BeTrue()) + Expect(track.MbzRecordingID).To(Equal("rec-1")) + Expect(track.Genres).To(Equal([]string{"Rock", "Pop"})) + Expect(track.CreatedAt).To(Equal(int64(1700000000))) + Expect(track.UpdatedAt).To(Equal(int64(1700000500))) + Expect(track.BirthTime).To(Equal(int64(1699999000))) + Expect(track.Tags).To(HaveKeyWithValue("isrc", []string{"US-XXX-00"})) + // Flat, role-tagged, role-sorted. + Expect(track.Participants).To(HaveLen(2)) + Expect(track.Participants[0]).To(Equal(types.ArtistRef{ + ID: "ar-1", Name: "My Artist", SortName: "artist, my", MBID: "mbz-ar-1", Role: "artist", + })) + Expect(track.Participants[1]).To(Equal(types.ArtistRef{ + ID: "ar-2", Name: "A Fiddler", Role: "performer", SubRole: "violin", + })) + // AverageRating is an aggregate, exposed even though the match is unscoped. + Expect(track.AverageRating).To(Equal(4.2)) + }) + + It("leaves nil-able numeric fields nil when absent", func() { + mf := &model.MediaFile{ID: "mf-2", Title: "No Optionals"} + track := newConverter(true).toTrack(mf, false) + Expect(track.BitDepth).To(BeNil()) + Expect(track.BPM).To(BeNil()) + Expect(track.RGAlbumGain).To(BeNil()) + Expect(track.RGAlbumPeak).To(BeNil()) + Expect(track.RGTrackGain).To(BeNil()) + Expect(track.RGTrackPeak).To(BeNil()) + }) + + It("preserves a real 0 ReplayGain value as non-nil", func() { + zero := 0.0 + mf := &model.MediaFile{ID: "mf-3", Title: "Zero RG", RGTrackGain: &zero} + track := newConverter(true).toTrack(mf, false) + Expect(track.RGTrackGain).To(HaveValue(Equal(0.0))) + Expect(track.RGAlbumGain).To(BeNil()) + }) + + It("exposes Path only when the plugin has filesystem permission", func() { + mf := &model.MediaFile{ID: "mf-4", Title: "With Path", Path: "/music/x.flac"} + Expect(newConverter(true).toTrack(mf, false).Path).To(Equal("/music/x.flac")) + Expect(newConverter(false).toTrack(mf, false).Path).To(BeEmpty()) + }) + }) + + Describe("MatchSongs", func() { + // The mock MediaFileRepo returns stored files (with annotations) verbatim, + // ignoring QueryOptions and the context user. These tests therefore cover the + // adapter's gating/access logic, not the SQL per-user join (a persistence-layer + // concern). + + // allowAll returns a service permitted to match as any user across all + // libraries. + allowAll := func(ds model.DataStore) host.MatcherService { + return newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + } + + It("returns one entry per input song in order, with nil for no-match", func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + // First (ID) phase returns the match for input song 0 only. + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-100", Title: "Hit", Artist: "Band"}, + }) + ds := &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), []types.SongRef{ + {ID: "mf-100", Name: "Hit", Artist: "Band"}, + {ID: "missing-id", Name: "Ghost", Artist: "Nobody"}, + }, host.MatchOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-100")) + Expect(results[1]).To(BeNil()) + }) + + It("returns an empty slice for empty input", func() { + ds := &tests.MockDataStore{MockedMediaFile: tests.CreateMockMediaFileRepo()} + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), nil, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + Context("with a scoped user", func() { + var ds *tests.MockDataStore + var userRepo *tests.MockedUserRepo + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mf := model.MediaFile{ID: "mf-1", Title: "Hit", Artist: "Band", LibraryID: 1} + mf.Starred = true + mf.Rating = 5 + mediaFileRepo.SetData(model.MediaFiles{mf}) + + userRepo = tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo, MockedUser: userRepo} + }) + + input := []types.SongRef{{ID: "mf-1", Name: "Hit", Artist: "Band"}} + + It("does not expose annotations when no username is given", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeFalse()) + Expect(results[0].Rating).To(BeZero()) + }) + + It("exposes the user's annotations when an allowed username is given", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-alice"}, false), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeTrue()) + Expect(results[0].Rating).To(Equal(int32(5))) + }) + + It("allows any username when allUsers is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0].Starred).To(BeTrue()) + }) + + It("returns an error for an unknown username", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + Expect(err).To(MatchError(ContainSubstring("not found"))) + }) + + It("surfaces a backend error rather than masking it as not-found", func() { + userRepo.Error = errors.New("db is locked") + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("db is locked"))) + Expect(err.Error()).ToNot(ContainSubstring("not found")) + }) + + It("returns an error for a username the plugin is not allowed to use", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-bob"}, false), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("not allowed"))) + }) + + It("rejects a username with the same error whether it exists, when the plugin has no user scope", func() { + // A plugin with no user scope (the only state a matcher-only plugin can + // be in) must not leak whether a username exists via the error text. + svc := newMatcherService(ds, false, newUserAccess(nil, false), newLibraryAccess(nil, true)) + + _, errExisting := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + _, errMissing := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + + Expect(errExisting).To(HaveOccurred()) + Expect(errExisting.Error()).To(Equal(errMissing.Error())) + Expect(errExisting.Error()).ToNot(ContainSubstring("not found")) + Expect(errExisting.Error()).To(ContainSubstring("not authorized to scope by user")) + }) + + It("does not inherit the caller's request user for an unscoped match", func() { + // The plugin may be invoked while handling another user's request; an + // unscoped match must run as admin, not as that inherited user. + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.IsAdmin).To(BeTrue()) + Expect(usr.ID).ToNot(Equal("u-caller")) + }) + + It("uses the requested user, overriding an inherited caller user", func() { + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.ID).To(Equal("u-alice")) + }) + }) + + Context("with plugin library access", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-lib1", Title: "A", Artist: "Band", LibraryID: 1}, + {ID: "mf-lib2", Title: "B", Artist: "Band", LibraryID: 2}, + }) + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + }) + + input := []types.SongRef{ + {ID: "mf-lib1", Name: "A", Artist: "Band"}, + {ID: "mf-lib2", Name: "B", Artist: "Band"}, + } + + It("drops matches from libraries the plugin cannot access", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess([]int{1}, false)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-lib1")) + Expect(results[1]).To(BeNil()) // library 2 not permitted + }) + + It("keeps all matches when allLibraries is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[1]).ToNot(BeNil()) + }) + + It("errors when the plugin has no library scope configured", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, false)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).To(MatchError(ContainSubstring("no libraries configured"))) + }) + }) + }) + + // Artist precedence for songRefToAgentSong is covered in metadata_agent_test.go; + // here we cover the duration normalization the matcher path relies on. + Describe("songRefToAgentSong duration", func() { + It("prefers DurationMs over the deprecated seconds field", func() { + song := songRefToAgentSong(types.SongRef{DurationMs: 247333, Duration: 99}) + Expect(song.Duration).To(Equal(uint32(247333))) + }) + + It("falls back to the seconds field when DurationMs is zero", func() { + song := songRefToAgentSong(types.SongRef{Duration: 210.5}) + Expect(song.Duration).To(Equal(uint32(210500))) + }) + + It("clamps a negative seconds duration to zero instead of overflowing", func() { + song := songRefToAgentSong(types.SongRef{Duration: -1}) + Expect(song.Duration).To(BeZero()) + }) + }) +}) + +var _ = Describe("MatcherService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "matcher-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + srcPath := filepath.Join(testdataDir, "test-matcher"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-matcher"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + // AllLibraries: the matcher requires a library scope. + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-matcher", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, + AllLibraries: true, + }}) + + mediaFileRepo := tests.CreateMockMediaFileRepo() + hit := model.MediaFile{ID: "mf-hit", Title: "Hit", Artist: "Band"} + hit.Starred = true + mediaFileRepo.SetData(model.MediaFiles{hit}) + + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + dataStore := &tests.MockDataStore{ + MockedPlugin: mockPluginRepo, + MockedMediaFile: mediaFileRepo, + MockedUser: userRepo, + } + + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + It("loads the plugin with the matcher permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-matcher"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Matcher).ToNot(BeNil()) + }) + + It("matches songs through the host boundary, preserving order and nils", func() { + ctx := GinkgoT().Context() + manager.mu.RLock() + p := manager.plugins["test-matcher"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(ctx) + + type tIn struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` + } + type tOut struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` + } + + call := func(in tIn) tOut { + inputBytes, err := json.Marshal(in) + Expect(err).ToNot(HaveOccurred()) + _, outputBytes, err := instance.Call("nd_test_matcher", inputBytes) + Expect(err).ToNot(HaveOccurred()) + var out tOut + Expect(json.Unmarshal(outputBytes, &out)).To(Succeed()) + Expect(out.Error).To(BeNil()) + return out + } + + songs := []types.SongRef{ + {ID: "mf-hit", Name: "Hit", Artist: "Band"}, + {ID: "nope", Name: "Ghost", Artist: "Nobody"}, + } + + By("matching without a user, preserving order and nils") + out := call(tIn{Songs: songs}) + Expect(out.MatchedIDs).To(HaveLen(2)) + Expect(out.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(out.MatchedIDs[1]).To(BeEmpty()) + Expect(out.Starred[0]).To(BeFalse()) // no user scope → no annotations + + By("matching as a user, exposing that user's annotations across the boundary") + scoped := call(tIn{Songs: songs, Username: "alice"}) + Expect(scoped.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(scoped.Starred[0]).To(BeTrue()) + }) +}) + +// ctxCapturingDataStore records the context passed to MediaFile so tests can assert +// which user the matcher resolved before querying the library. +type ctxCapturingDataStore struct { + *tests.MockDataStore + lastMediaFileCtx context.Context +} + +func (d *ctxCapturingDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + d.lastMediaFileCtx = ctx + return d.MockDataStore.MediaFile(ctx) +} diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index 334d9b738..ca53aed56 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -51,7 +51,7 @@ var _ = Describe("SchedulerService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Create mock scheduler and timer registry diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index 01a33c039..dba58d795 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1" // URL Format: Only the path and query parameters are used - host/protocol are ignored. // Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { - pluginID string - router SubsonicRouter - ds model.DataStore - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - userIDMap map[string]struct{} + pluginID string + router SubsonicRouter + ds model.DataStore + users userAccess // users this plugin may act as (from DB configuration) } // newSubsonicAPIService creates a new SubsonicAPIService for a plugin. -func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService { - userIDMap := make(map[string]struct{}) - for _, id := range allowedUserIDs { - userIDMap[id] = struct{}{} - } +func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService { return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - allowedUserIDs: allowedUserIDs, - allUsers: allUsers, - userIDMap: userIDMap, + pluginID: pluginID, + router: router, + ds: ds, + users: users, } } @@ -136,12 +128,12 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user - if s.allUsers { + if s.users.allUsers { return nil } // Must have at least one allowed user ID configured - if len(s.allowedUserIDs) == 0 { + if len(s.users.userIDSet) == 0 { return fmt.Errorf("no users configured for plugin %s", s.pluginID) } @@ -155,7 +147,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Check if the user's ID is in the allowed list - if _, ok := s.userIDMap[usr.ID]; !ok { + if !s.users.allows(usr.ID) { return fmt.Errorf("user %s is not authorized for this plugin", username) } diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 607f3a64b..c3d9ffe8a 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -44,7 +44,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock router and data store @@ -267,7 +267,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with specific user IDs allowed", func() { It("blocks users not in the allowed list", func() { // allowedUserIDs contains "user2", but testuser is "user1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -277,7 +277,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows users in the allowed list", func() { // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") @@ -287,7 +287,7 @@ var _ = Describe("SubsonicAPIService", func() { It("blocks admin users when not in allowed list", func() { // allowedUserIDs only contains "user1" (testuser), not "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") @@ -297,7 +297,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when in allowed list", func() { // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -308,7 +308,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with allUsers=true", func() { It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") @@ -317,7 +317,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -328,7 +328,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with no users configured", func() { It("returns error when no users are configured", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -337,7 +337,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -349,7 +349,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("URL Handling", func() { It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") @@ -358,7 +358,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") @@ -367,7 +367,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") @@ -380,7 +380,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("CallRaw", func() { It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -390,7 +390,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -402,7 +402,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -411,7 +411,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") @@ -420,7 +420,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") @@ -429,7 +429,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") @@ -440,7 +440,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("Router Availability", func() { It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index 9f2ed85f6..2f74c0aa4 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path/filepath" "sync" @@ -81,8 +82,9 @@ type taskQueueServiceImpl struct { } // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. -func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) +// 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) } @@ -101,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, @@ -540,9 +542,7 @@ func (s *taskQueueServiceImpl) cleanupLoop() { func (s *taskQueueServiceImpl) runCleanup() { s.mu.Lock() queues := make(map[string]*queueState, len(s.queues)) - for k, v := range s.queues { - queues[k] = v - } + maps.Copy(queues, s.queues) s.mu.Unlock() now := time.Now().UnixMilli() diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index c3ab8d119..d459fd69b 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -40,17 +40,13 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + 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()) }) @@ -367,8 +363,8 @@ var _ = Describe("TaskQueueService", func() { // Enqueue several more tasks — they stay pending since the worker is busy var pendingIDs []string - for i := 0; i < 3; i++ { - taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i))) + for i := range 3 { + taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) pendingIDs = append(pendingIDs, taskID) } @@ -674,8 +670,8 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) // Enqueue 5 tasks - for i := 0; i < 5; i++ { - _, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i))) + for i := range 5 { + _, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i)) 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() @@ -853,10 +843,10 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") - conf.Server.DataFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache")) + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() @@ -1112,7 +1102,7 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // the second will be dequeued but block on the rate limiter (status=running), // the rest will stay pending. var taskIDs []string - for i := 0; i < 5; i++ { + for range 5 { output, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-cancel", @@ -1186,11 +1176,11 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Enqueue several tasks - for i := 0; i < 4; i++ { + for i := range 4 { _, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-clear", - Payload: []byte(fmt.Sprintf("task-%d", i)), + Payload: fmt.Appendf(nil, "task-%d", i), }) Expect(err).ToNot(HaveOccurred()) } diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 1c0de7d03..42f6a3032 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -484,7 +484,7 @@ func createTestUsers(mockUserRepo *tests.MockedUserRepo) { // setupTestUsersConfig sets up common plugin configuration func setupTestUsersConfig(tmpDir string) { conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false } diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 74238a422..90403f4c0 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -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 @@ -302,8 +305,7 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { closeCode := websocket.CloseNoStatusReceived closeReason := "" - var ce *websocket.CloseError - if errors.As(err, &ce) { + if ce, ok := errors.AsType[*websocket.CloseError](err); ok { closeCode = ce.Code closeReason = ce.Text } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 83fca9898..e41cfbb82 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -51,7 +51,7 @@ var _ = Describe("WebSocketService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index c66b027d7..281f022fb 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -31,8 +31,8 @@ type LyricsPlugin struct { plugin *plugin } -// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses -// using model.ToLyrics. +// 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) { req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), @@ -44,19 +44,25 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode return nil, err } + // The lyric text comes from the plugin, not the media file's own tags, so + // attribute logs to both the plugin and the track it was fetched for. + ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path) + var result model.LyricList for _, lt := range resp.Lyrics { lang := lt.Lang if lang == "" { lang = "xxx" } - parsed, err := model.ToLyrics(lang, lt.Text) + parsed, err := model.ParseLyrics(ctx, "", lang, []byte(lt.Text)) if err != nil { - log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) + log.Warn(ctx, "Error parsing plugin lyrics", err) continue } - if parsed != nil && !parsed.IsEmpty() { - result = append(result, *parsed) + for _, lyric := range parsed { + if !lyric.IsEmpty() { + result = append(result, lyric) + } } } return result, nil diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index a1a6c1809..6e82dbfab 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -83,6 +83,32 @@ var _ = Describe("LyricsPlugin", Ordered, func() { _, err := p.GetLyrics(GinkgoT().Context(), track) Expect(err).To(HaveOccurred()) }) + + // Each DescribeTable entry proves that the adapter's content-sniffing routes + // the plugin's rich payload to the right parser rather than mangling it as plain text. + DescribeTable("content-sniffs plugin responses across all supported formats", + func(format string, wantSynced bool, wantLine string) { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"format": format}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format) + Expect(result[0].Line).To(HaveLen(1)) + Expect(result[0].Line[0].Value).To(Equal(wantLine)) + }, + Entry("ttml", "ttml", true, "plugin ttml line"), + Entry("srt", "srt", true, "plugin srt line"), + Entry("yaml", "yaml", true, "plugin yaml line"), + Entry("lrc", "lrc", true, "plugin lrc line"), + Entry("plain", "plain", false, "plugin plain line"), + ) }) Describe("PluginNames", func() { diff --git a/plugins/manager.go b/plugins/manager.go index 0e9419bfd..b3a71ce6c 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "path/filepath" "runtime" "sync" @@ -124,7 +123,7 @@ func (m *Manager) Start(ctx context.Context) error { m.ctx, m.cancel = context.WithCancel(ctx) // Initialize wazero compilation cache for better performance - cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") + cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) var err error @@ -134,17 +133,12 @@ func (m *Manager) Start(ctx context.Context) error { return fmt.Errorf("creating wazero compilation cache: %w", err) } - folder := conf.Server.Plugins.Folder - if folder == "" { + if conf.Server.Plugins.Folder.String() == "" { log.Debug(ctx, "No plugins folder configured") return nil } - // Create plugins folder if it doesn't exist - if err := os.MkdirAll(folder, 0755); err != nil { - log.Error(ctx, "Failed to create plugins folder", "folder", folder, err) - return fmt.Errorf("creating plugins folder: %w", err) - } + folder := conf.Server.Plugins.Folder.MustPath() log.Info(ctx, "Starting plugin manager", "folder", folder) @@ -247,7 +241,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { +func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) { return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) } @@ -388,7 +382,7 @@ func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON strin return fmt.Errorf("getting plugin from DB: %w", err) } - manifest, err := readManifest(plugin.Path) + manifest, err := ReadManifest(plugin.Path) if err != nil { return fmt.Errorf("reading manifest: %w", err) } @@ -431,7 +425,7 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s // This synchronizes the database with the filesystem, discovering new plugins, // updating changed ones, and removing deleted ones. func (m *Manager) RescanPlugins(ctx context.Context) error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return fmt.Errorf("plugins folder not configured") } @@ -466,7 +460,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn shouldDisable := false disableReason := "" if wasEnabled { - manifest, err := readManifest(plugin.Path) + manifest, err := ReadManifest(plugin.Path) if err == nil && manifest.Permissions != nil { if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) { shouldDisable = true @@ -597,7 +591,7 @@ func (m *Manager) UnloadDisabledPlugins(ctx context.Context) { // before a plugin can be enabled. Returns an error if any gate condition fails. func (m *Manager) checkPermissionGates(p *model.Plugin) error { // Parse manifest to check permissions - manifest, err := readManifest(p.Path) + manifest, err := ReadManifest(p.Path) if err != nil { return fmt.Errorf("reading manifest: %w", err) } diff --git a/plugins/manager_cache_test.go b/plugins/manager_cache_test.go index f985fcd84..3dbfa45ee 100644 --- a/plugins/manager_cache_test.go +++ b/plugins/manager_cache_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strconv" "time" "github.com/dustin/go-humanize" @@ -143,7 +144,7 @@ var _ = Describe("purgeCacheBySize", func() { // Create 5 files, 1MiB each (total 5MiB) for i := range 5 { - path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) + path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index ccda9e4cb..757ededb5 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -30,11 +30,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,106 +55,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) { - service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) - return host.RegisterSubsonicAPIHostFunctions(service), nil + 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, 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, 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, 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 }, }, } @@ -155,12 +178,12 @@ func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) { return nil, fmt.Errorf("manager is stopped") } - manifest, err := readManifest(ndpPath) + manifest, err := ReadManifest(ndpPath) if err != nil { return nil, err } - sha256Hash, err := computeFileSHA256(ndpPath) + sha256Hash, err := ComputeFileSHA256(ndpPath) if err != nil { return nil, fmt.Errorf("computing hash: %w", err) } @@ -243,6 +266,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() { @@ -315,6 +339,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, @@ -328,7 +361,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) @@ -387,6 +423,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), } m.mu.Unlock() + loaded = true // Call plugin init function callPluginInit(ctx, m.plugins[p.ID]) @@ -394,6 +431,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) { diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go new file mode 100644 index 000000000..8f35548af --- /dev/null +++ b/plugins/manager_loader_load_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadPluginWithConfig", func() { + var manager *Manager + var dataDir string + + BeforeEach(func() { + pluginsDir := GinkgoT().TempDir() + dataDir = GinkgoT().TempDir() + + src := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension) + data, err := os.ReadFile(src) + Expect(err).ToNot(HaveOccurred()) + dest := filepath.Join(pluginsDir, "test-taskqueue"+PackageExtension) + Expect(os.WriteFile(dest, data, 0600)).To(Succeed()) + hash := sha256.Sum256(data) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(pluginsDir) + conf.Server.Plugins.AutoReload = false + conf.Server.DataFolder = conf.NewDir(dataDir) + + repo := tests.CreateMockPluginRepo() + repo.Permitted = true + repo.SetData(model.Plugins{{ + ID: "test-taskqueue", + Path: dest, + SHA256: hex.EncodeToString(hash[:]), + Enabled: false, + }}) + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: repo}, + metrics: noopMetricsRecorder{}, + subsonicRouter: http.NotFoundHandler(), + } + }) + + Describe("host service creation failures", func() { + It("reports the Task service creation error instead of a missing host function", func() { + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + DeferCleanup(func() { _ = manager.Stop() }) + + // Block the taskqueue data dir by creating a file where the directory should be + Expect(os.WriteFile(filepath.Join(dataDir, "plugins"), nil, 0600)).To(Succeed()) + + err := manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue") + Expect(err).To(MatchError(ContainSubstring("creating Task service"))) + Expect(err).ToNot(MatchError(ContainSubstring("not exported"))) + }) + }) + + Describe("unstarted manager", func() { + It("enables a taskqueue plugin on a manager that was never started", func() { + // CLI commands (navidrome plugin enable) use the manager without calling Start + Expect(manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue")).To(Succeed()) + DeferCleanup(func() { _ = manager.unloadPlugin("test-taskqueue") }) + }) + }) +}) diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 1d4a8c301..f0c7c56d5 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -4,9 +4,11 @@ import ( "context" "crypto/rand" "errors" + "fmt" "io" extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/model" "github.com/tetratelabs/wazero" ) @@ -75,3 +77,58 @@ func (a libraryAccess) contains(libID int) bool { _, ok := a.libraryIDSet[libID] return ok } + +// configured reports whether the plugin has any library scope (all, or specific). +func (a libraryAccess) configured() bool { + return a.allLibraries || len(a.libraryIDSet) > 0 +} + +// userAccess captures the set of users a plugin is permitted to act as, +// precomputed at load time for O(1) lookup. +type userAccess struct { + allUsers bool + userIDSet map[string]struct{} +} + +func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess { + set := make(map[string]struct{}, len(allowedUserIDs)) + for _, id := range allowedUserIDs { + set[id] = struct{}{} + } + return userAccess{allUsers: allUsers, userIDSet: set} +} + +// allows reports whether the plugin may act as the given user ID. +func (a userAccess) allows(userID string) bool { + if a.allUsers { + return true + } + _, ok := a.userIDSet[userID] + return ok +} + +// resolve looks up a user by username and authorizes it against this access set, +// distinguishing an absent user from a backend failure. +// +// When the plugin has no user scope at all, it rejects before the lookup with a +// single fixed error, so a caller cannot tell a real account from a missing one by +// the error text (username enumeration). +func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) { + if !a.allUsers && len(a.userIDSet) == 0 { + return nil, fmt.Errorf("plugin is not authorized to scope by user") + } + usr, err := ds.User(ctx).FindByUsername(username) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("user %q not found", username) + } + return nil, fmt.Errorf("looking up user %q: %w", username, err) + } + if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil) + return nil, fmt.Errorf("user %q not found", username) + } + if !a.allows(usr.ID) { + return nil, fmt.Errorf("plugin is not allowed to act as user %q", username) + } + return usr, nil +} diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index f97069e74..2119f1a5a 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -36,9 +37,9 @@ func marshalManifest(m *Manifest) string { return string(b) } -// computeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. +// ComputeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. // This is used for quick change detection before full plugin compilation. -func computeFileSHA256(path string) (string, error) { +func ComputeFileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err @@ -107,6 +108,16 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos if err := repo.Delete(pluginID); err != nil { return fmt.Errorf("deleting plugin from DB: %w", err) } + // Discard any scrobbles still buffered for the removed plugin, so they are + // not delivered to an unrelated plugin that reuses the same name later. + // Skip names owned by builtin scrobblers: buffer entries are keyed by + // service name, so removing a plugin file named e.g. "lastfm.ndp" must not + // wipe the builtin Last.fm retry queue. + if scrobbler.IsBuiltinScrobbler(pluginID) { + log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID) + } else if err := m.ds.ScrobbleBuffer(ctx).Discard(pluginID); err != nil { + log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, err) + } log.Info(ctx, "Plugin removed", "plugin", pluginID) m.sendPluginRefreshEvent(ctx, events.Any) return nil @@ -165,7 +176,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { dbPlugin, exists := pluginsInDB[name] // Compute SHA256 first (lightweight operation) to check if plugin changed - sha256Hash, err := computeFileSHA256(path) + sha256Hash, err := ComputeFileSHA256(path) if err != nil { log.Error(ctx, "Failed to compute SHA256 for plugin", "plugin", name, "path", path, err) continue diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go new file mode 100644 index 000000000..26da2079b --- /dev/null +++ b/plugins/manager_sync_test.go @@ -0,0 +1,85 @@ +package plugins + +import ( + "context" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("removePluginFromDB", func() { + It("discards buffered scrobbles for the removed plugin", func() { + ctx := context.Background() + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("my-plugin", "user1", "track1", time.Now())).To(Succeed()) + Expect(buffer.Enqueue("other-plugin", "user1", "track2", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "my-plugin", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + // No broker: sendPluginRefreshEvent is nil-safe, and testBroker is + // defined in manager_test.go, which is excluded on Windows. + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).To(MatchError(model.ErrNotFound)) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1))) + entry, err := buffer.Next("other-plugin", "user1") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil(), "entries of other services must be kept") + }) + + It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() { + ctx := context.Background() + scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("builtin-svc", "user1", "track1", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "builtin-svc", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped") + }) +}) + +var _ = Describe("ComputeFileSHA256", func() { + It("returns a consistent 64-char lowercase hex hash for the same file", func() { + dir := GinkgoT().TempDir() + ndpPath := filepath.Join(dir, "test.ndp") + err := createTestPackage(ndpPath, &Manifest{Name: "S", Author: "a", Version: "1.0.0"}, []byte{0x00, 0x61, 0x73, 0x6d}) + Expect(err).ToNot(HaveOccurred()) + + hash1, err := ComputeFileSHA256(ndpPath) + Expect(err).ToNot(HaveOccurred()) + hash2, err := ComputeFileSHA256(ndpPath) + Expect(err).ToNot(HaveOccurred()) + + Expect(hash1).To(Equal(hash2)) + Expect(hash1).To(MatchRegexp(`^[0-9a-f]{64}$`)) + }) + + It("returns an error for a non-existent path", func() { + _, err := ComputeFileSHA256(filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp")) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index 4f266bda1..d666d8620 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -19,7 +19,7 @@ const debounceDuration = 2 * time.Second // startWatcher starts the file watcher for the plugins folder. // It watches for CREATE, WRITE, and REMOVE events on .wasm files. func (m *Manager) startWatcher() error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return nil } @@ -146,7 +146,7 @@ func (m *Manager) processPluginEvent(pluginName string) { delete(m.debounceTimers, pluginName) m.debounceMu.Unlock() - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() ndpPath := filepath.Join(folder, pluginName+PackageExtension) action := determinePluginAction(ndpPath) @@ -158,7 +158,7 @@ func (m *Manager) processPluginEvent(pluginName string) { switch action { case actionUpdate: // File changed - check SHA256 first, then extract manifest if needed - sha256Hash, err := computeFileSHA256(ndpPath) + sha256Hash, err := ComputeFileSHA256(ndpPath) if err != nil { log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err) return diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index c15a3bf3d..29e5d1fc7 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -113,6 +113,9 @@ }, "taskqueue": { "$ref": "#/$defs/TaskQueuePermission" + }, + "matcher": { + "$ref": "#/$defs/MatcherPermission" } } }, @@ -254,6 +257,17 @@ "description": "Explanation for why users access is needed" } } + }, + "MatcherPermission": { + "type": "object", + "description": "Matcher service permissions for resolving external songs to local library tracks", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why matcher access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 7484718e3..6bd0e8049 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -3,10 +3,37 @@ package plugins import ( "encoding/json" "fmt" + "reflect" + "sort" + "strings" "github.com/santhosh-tekuri/jsonschema/v6" ) +// DeclaredNames returns the sorted names of the non-nil permission fields. It +// reflects over the generated json tags so new permission types are picked up +// automatically rather than via a hand-maintained list. +func (p *Permissions) DeclaredNames() []string { + if p == nil { + return nil + } + var names []string + v := reflect.ValueOf(*p) + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := v.Field(i) + if f.Kind() != reflect.Pointer || f.IsNil() { + continue + } + tag := t.Field(i).Tag.Get("json") + if name, _, _ := strings.Cut(tag, ","); name != "" && name != "-" { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + //go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json // ParseManifest unmarshals manifest JSON and performs cross-field validation. @@ -32,6 +59,14 @@ func (m *Manifest) Validate() error { } } + // Matcher returns library content, so it requires the library permission (which + // is what exposes a library scope for configuration). + if m.Permissions != nil && m.Permissions.Matcher != nil { + if m.Permissions.Library == nil { + return fmt.Errorf("'matcher' permission requires 'library' permission to be declared") + } + } + // Validate config schema if present if m.Config != nil && m.Config.Schema != nil { if err := validateConfigSchema(m.Config.Schema); err != nil { diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index efe93e05f..3599eafc4 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -158,6 +158,12 @@ func (j *Manifest) UnmarshalJSON(value []byte) error { return nil } +// Matcher service permissions for resolving external songs to local library tracks +type MatcherPermission struct { + // Explanation for why matcher access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Permissions required by the plugin type Permissions struct { // Artwork corresponds to the JSON schema field "artwork". @@ -175,6 +181,9 @@ type Permissions struct { // Library corresponds to the JSON schema field "library". Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"` + // Matcher corresponds to the JSON schema field "matcher". + Matcher *MatcherPermission `json:"matcher,omitempty" yaml:"matcher,omitempty" mapstructure:"matcher,omitempty"` + // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index c45a480eb..2a8b0dcfa 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -140,11 +140,10 @@ var _ = Describe("Manifest", func() { }) It("returns true when threads feature has a reason", func() { - reason := "Required for concurrent processing" m := &Manifest{ Experimental: &Experimental{ Threads: &ThreadsFeature{ - Reason: &reason, + Reason: new("Required for concurrent processing"), }, }, } @@ -262,6 +261,37 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("subsonicapi")) }) + It("validates manifest with matcher and library permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + Library: &LibraryPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when matcher without library permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("matcher")) + Expect(err.Error()).To(ContainSubstring("library")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", @@ -465,3 +495,27 @@ var _ = Describe("Manifest", func() { }) }) }) + +var _ = Describe("Permissions.DeclaredNames", func() { + It("returns nil for a nil receiver", func() { + var p *Permissions + Expect(p.DeclaredNames()).To(BeEmpty()) + }) + + It("returns declared names sorted", func() { + p := &Permissions{ + Subsonicapi: &SubsonicAPIPermission{}, + Users: &UsersPermission{}, + } + Expect(p.DeclaredNames()).To(Equal([]string{"subsonicapi", "users"})) + }) + + It("returns all declared names sorted regardless of field order", func() { + p := &Permissions{ + Http: &HTTPPermission{}, + Artwork: &ArtworkPermission{}, + Cache: &CachePermission{}, + } + Expect(p.DeclaredNames()).To(Equal([]string{"artwork", "cache", "http"})) + }) +}) diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 52542300c..e5d6d43fc 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" "github.com/navidrome/navidrome/utils/slice" ) @@ -227,23 +228,34 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } -// songRefToAgentSong converts a single SongRef to agents.Song -func songRefToAgentSong(s capabilities.SongRef) agents.Song { +// songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single +// Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead +// of the artists array, they are folded into a one-element Artists list here. +func songRefToAgentSong(s types.SongRef) agents.Song { + var artists []agents.Artist + switch { + case len(s.Artists) > 0: + artists = make([]agents.Artist, len(s.Artists)) + for i, a := range s.Artists { + artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID} + } + case s.Artist != "" || s.ArtistMBID != "": + artists = []agents.Artist{{Name: s.Artist, MBID: s.ArtistMBID}} + } return agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artists: artists, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: s.DurationInMs(), } } // songRefsToAgentSongs converts a slice of SongRef to agents.Song -func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { +func songRefsToAgentSongs(refs []types.SongRef) []agents.Song { return slice.Map(refs, songRefToAgentSong) } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 067ae80ca..a14db5d34 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -4,6 +4,7 @@ package plugins import ( "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -118,7 +119,8 @@ var _ = Describe("MetadataAgent", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) Expect(songs[0].Name).To(Equal("Similar to Yesterday #1")) - Expect(songs[0].Artist).To(Equal("The Beatles")) + Expect(songs[0].Artists).To(HaveLen(1)) + Expect(songs[0].Artists[0].Name).To(Equal("The Beatles")) }) }) @@ -329,3 +331,31 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { Expect(err).To(MatchError(errNotImplemented)) }) }) + +var _ = Describe("songRefToAgentSong multi-artist", func() { + It("maps ArtistRef to agents.Artist", func() { + ref := capabilities.SongRef{Name: "Collab", Artist: "Drake", Artists: []capabilities.ArtistRef{ + {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, + {Name: "Future", MBID: "m-future"}, + }} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{ + {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, + {Name: "Future", MBID: "m-future"}, + })) + }) + It("folds the single Artist/ArtistMBID into a one-element Artists when no Artists provided", func() { + ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{{Name: "Drake", MBID: "m-drake"}})) + }) + It("folds an MBID-only single artist (empty name) so the MBID is not dropped", func() { + ref := capabilities.SongRef{Name: "Solo", ArtistMBID: "m-drake"} + got := songRefToAgentSong(ref) + Expect(got.Artists).To(Equal([]agents.Artist{{MBID: "m-drake"}})) + }) + It("leaves Artists nil when neither Artists nor the single Artist/ArtistMBID are provided", func() { + got := songRefToAgentSong(capabilities.SongRef{Name: "Anon"}) + Expect(got.Artists).To(BeNil()) + }) +}) diff --git a/plugins/package.go b/plugins/package.go index 475761231..e0405d4d8 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -72,9 +72,10 @@ func openPackage(ndpPath string) (*ndpPackage, error) { }, nil } -// readManifest reads only the manifest from an .ndp file without loading the wasm bytes. -// This is useful for quick plugin discovery. -func readManifest(ndpPath string) (*Manifest, error) { +// ReadManifest reads and validates the manifest from a .ndp file without loading +// the wasm bytes (it runs ParseManifest, so JSON-schema and cross-field +// validation are applied). Useful for quick plugin discovery and validation. +func ReadManifest(ndpPath string) (*Manifest, error) { // Open the zip archive zr, err := zip.OpenReader(ndpPath) if err != nil { diff --git a/plugins/package_test.go b/plugins/package_test.go index fa76ddd94..4a37f4352 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -132,67 +132,69 @@ var _ = Describe("ndpPackage", func() { }) }) - Describe("readManifest", func() { - It("should read only the manifest without loading wasm", func() { + Describe("ReadManifest", func() { + It("parses the manifest from a package that also contains wasm", func() { ndpPath := filepath.Join(tmpDir, "test.ndp") - desc := "A test plugin" manifest := &Manifest{ Name: "Test Plugin", Author: "Test Author", Version: "1.0.0", - Description: &desc, + Description: new("A test plugin"), } - wasmBytes := make([]byte, 1024*1024) // 1MB of zeros - err := createTestPackage(ndpPath, manifest, wasmBytes) + err := createTestPackage(ndpPath, manifest, nil) Expect(err).ToNot(HaveOccurred()) - m, err := readManifest(ndpPath) + m, err := ReadManifest(ndpPath) Expect(err).ToNot(HaveOccurred()) Expect(m.Name).To(Equal("Test Plugin")) Expect(*m.Description).To(Equal("A test plugin")) }) - It("should return error for missing manifest", func() { - ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") + It("returns an error for a non-existent file", func() { + _, err := ReadManifest(filepath.Join(tmpDir, "does-not-exist.ndp")) + Expect(err).To(HaveOccurred()) + }) + It("returns a specific error for a package missing manifest.json", func() { + ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") f, err := os.Create(ndpPath) Expect(err).ToNot(HaveOccurred()) defer f.Close() - zw := newTestZipWriter(f) - err = zw.addFile("plugin.wasm", []byte{0x00}) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) + Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed()) + Expect(zw.close()).To(Succeed()) - _, err = readManifest(ndpPath) + _, err = ReadManifest(ndpPath) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing manifest.json")) }) - }) - Describe("ComputePackageSHA256", func() { - It("should compute consistent hash for same file", func() { - ndpPath := filepath.Join(tmpDir, "test.ndp") + It("fails for a package with a schema-invalid manifest", func() { + ndp := filepath.Join(tmpDir, "bad.ndp") + // empty required fields violate the manifest JSON schema + err := createTestPackage(ndp, &Manifest{}, nil) + Expect(err).ToNot(HaveOccurred()) + _, err = ReadManifest(ndp) + Expect(err).To(HaveOccurred()) + }) + + It("enforces cross-field validation", func() { + ndp := filepath.Join(tmpDir, "crossfield.ndp") + // subsonicapi permission without users: violates cross-field rule manifest := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", + Name: "X", + Author: "me", + Version: "1.0.0", + Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}}, } - wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} - - err := createTestPackage(ndpPath, manifest, wasmBytes) + err := createTestPackage(ndp, manifest, nil) Expect(err).ToNot(HaveOccurred()) - hash1, err := computeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - hash2, err := computeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - Expect(hash1).To(Equal(hash2)) - Expect(hash1).To(HaveLen(64)) // SHA-256 produces 64 hex characters + _, err = ReadManifest(ndp) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("subsonicapi")) + Expect(err.Error()).To(ContainSubstring("users")) }) }) }) diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 5781a04c1..ff2c2a07f 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -41,6 +41,7 @@ The following host services are available: - HTTP: provides outbound HTTP request capabilities for plugins. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. + - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_artwork_stub.go b/plugins/pdk/go/host/nd_host_artwork_stub.go index aa41e440c..3b81e0d6b 100644 --- a/plugins/pdk/go/host/nd_host_artwork_stub.go +++ b/plugins/pdk/go/host/nd_host_artwork_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockArtworkService is the mock implementation for testing. type mockArtworkService struct { diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go index fbd80d13f..46bb44bcf 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockCacheService is the mock implementation for testing. type mockCacheService struct { diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 2b8485ce9..463c29b76 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockConfigService is the mock implementation for testing. type mockConfigService struct { diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 2f15a91a9..09c4d0fcf 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // HTTPRequest represents the HTTPRequest data structure. // HTTPRequest represents an outbound HTTP request from a plugin. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index 83b55d3a8..fce038aa1 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockKVStoreService is the mock implementation for testing. type mockKVStoreService struct { diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 9ad0d97e7..1e4c230c3 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // Library represents the Library data structure. // Library represents a music library with metadata. diff --git a/plugins/pdk/go/host/nd_host_matcher.go b/plugins/pdk/go/host/nd_host_matcher.go new file mode 100644 index 000000000..b32ac6f9d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher.go @@ -0,0 +1,77 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/types" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// matcher_matchsongs is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user matcher_matchsongs +func matcher_matchsongs(uint64) uint64 + +type matcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +type matcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// MatcherMatchSongs calls the matcher_matchsongs host function. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + // Marshal request to JSON + req := matcherMatchSongsRequest{ + Songs: songs, + Opts: opts, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := matcher_matchsongs(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response matcherMatchSongsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Results, nil +} diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go new file mode 100644 index 000000000..07b34bb82 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher_stub.go @@ -0,0 +1,44 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/types" + "github.com/stretchr/testify/mock" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// mockMatcherService is the mock implementation for testing. +type mockMatcherService struct { + mock.Mock +} + +// MatcherMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.MatcherMock.On("MethodName", args...).Return(values...) +var MatcherMock = &mockMatcherService{} + +// MatchSongs is the mock method for MatcherMatchSongs. +func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + args := m.Called(songs, opts) + return args.Get(0).([]*types.Track), args.Error(1) +} + +// MatcherMatchSongs delegates to the mock instance. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + return MatcherMock.MatchSongs(songs, opts) +} diff --git a/plugins/pdk/go/host/nd_host_scheduler_stub.go b/plugins/pdk/go/host/nd_host_scheduler_stub.go index 3eaa0087a..ac2a8821f 100644 --- a/plugins/pdk/go/host/nd_host_scheduler_stub.go +++ b/plugins/pdk/go/host/nd_host_scheduler_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSchedulerService is the mock implementation for testing. type mockSchedulerService struct { diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 2fdaf2403..6d3a56b35 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockSubsonicAPIService is the mock implementation for testing. type mockSubsonicAPIService struct { diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index 4dde0e234..286d14035 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // QueueConfig represents the QueueConfig data structure. // QueueConfig holds configuration for a task queue. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index f76854894..8858b2109 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // User represents the User data structure. // User represents a Navidrome user with minimal information exposed to plugins. diff --git a/plugins/pdk/go/host/nd_host_websocket_stub.go b/plugins/pdk/go/host/nd_host_websocket_stub.go index 23ac382f0..ca6e39c44 100644 --- a/plugins/pdk/go/host/nd_host_websocket_stub.go +++ b/plugins/pdk/go/host/nd_host_websocket_stub.go @@ -8,7 +8,9 @@ package host -import "github.com/stretchr/testify/mock" +import ( + "github.com/stretchr/testify/mock" +) // mockWebSocketService is the mock implementation for testing. type mockWebSocketService struct { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 6696bc4a1..b3c7f29ac 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -9,17 +9,11 @@ package lyrics import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -51,9 +45,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index fb3e3fb1a..76c54d2e5 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -8,15 +8,10 @@ package lyrics -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -48,9 +43,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index 7cd63865b..c561c2893 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -9,8 +9,15 @@ package metadata import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -65,16 +72,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -114,7 +111,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -160,29 +157,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -200,7 +175,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index bdcd06fcb..e72cca103 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -8,6 +8,14 @@ package metadata +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -62,16 +70,6 @@ type ArtistMBIDResponse struct { MBID string `json:"mbid"` } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // ArtistRequest is the common request for artist-related functions. type ArtistRequest struct { // ID is the internal Navidrome artist ID. @@ -111,7 +109,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -157,29 +155,7 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []SongRef `json:"songs"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Songs []types.SongRef `json:"songs"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -197,7 +173,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []SongRef `json:"songs"` + Songs []types.SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 0d045e597..22ae4c3bf 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -9,8 +9,12 @@ package scrobbler import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -26,16 +30,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -95,9 +89,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index b35e7c40e..722ec5462 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -8,6 +8,11 @@ package scrobbler +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string @@ -23,16 +28,6 @@ const ( // Error implements the error interface for ScrobblerError. func (e ScrobblerError) Error() string { return string(e) } -// ArtistRef is a reference to an artist with name and optional MBID. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` -} - // IsAuthorizedRequest is the request for authorization check. type IsAuthorizedRequest struct { // Username is the username of the user. @@ -92,9 +87,9 @@ type TrackInfo struct { // AlbumArtist is the formatted album artist name for display. AlbumArtist string `json:"albumArtist"` // Artists is the list of track artists. - Artists []ArtistRef `json:"artists"` + Artists []types.ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []ArtistRef `json:"albumArtists"` + AlbumArtists []types.ArtistRef `json:"albumArtists"` // Duration is the track duration in seconds. Duration float32 `json:"duration"` // TrackNumber is the track number on the album. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go index 7a2681f93..fca8bd2c1 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -9,47 +9,32 @@ package sonicsimilarity import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go index 28d86301c..518a166e5 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -8,45 +8,31 @@ package sonicsimilarity +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +// Deprecated: use types.ArtistRef. +type ArtistRef = types.ArtistRef + +// Deprecated: use types.SongRef. +type SongRef = types.SongRef + // FindSonicPathRequest represents the FindSonicPathRequest data structure. type FindSonicPathRequest struct { - StartSong SongRef `json:"startSong"` - EndSong SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong types.SongRef `json:"startSong"` + EndSong types.SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SongRef is a reference to a song with metadata for matching. -type SongRef struct { - // ID is the internal Navidrome mediafile ID (if known). - ID string `json:"id,omitempty"` - // Name is the song name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the song. - MBID string `json:"mbid,omitempty"` - // ISRC is the International Standard Recording Code for the song. - ISRC string `json:"isrc,omitempty"` - // Artist is the artist name. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - ArtistMBID string `json:"artistMbid,omitempty"` - // Album is the album name. - Album string `json:"album,omitempty"` - // AlbumMBID is the MusicBrainz release ID. - AlbumMBID string `json:"albumMbid,omitempty"` - // Duration is the song duration in seconds. - Duration float32 `json:"duration,omitempty"` + Song types.SongRef `json:"song"` + Count int32 `json:"count"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song types.SongRef `json:"song"` + Similarity float64 `json:"similarity"` } // SonicSimilarityResponse represents the SonicSimilarityResponse data structure. diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go new file mode 100644 index 000000000..4d4cdedd2 --- /dev/null +++ b/plugins/pdk/go/types/types.go @@ -0,0 +1,148 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// Package types holds the stable, shared data types exchanged between +// Navidrome and its plugins. These types are referenced by host services and +// capability wrappers via the types package. + +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + // + // Deprecated: use Artists. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. + Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` +} diff --git a/plugins/pdk/python/host/nd_host_artwork.py b/plugins/pdk/python/host/nd_host_artwork.py deleted file mode 100644 index 9bcb529ae..000000000 --- a/plugins/pdk/python/host/nd_host_artwork.py +++ /dev/null @@ -1,183 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Artwork host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "artwork_getartisturl") -def _artwork_getartisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getalbumurl") -def _artwork_getalbumurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_gettrackurl") -def _artwork_gettrackurl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "artwork_getplaylisturl") -def _artwork_getplaylisturl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def artwork_get_artist_url(id: str, size: int) -> str: - """GetArtistUrl generates a public URL for an artist's artwork. - -Parameters: - - id: The artist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getartisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_album_url(id: str, size: int) -> str: - """GetAlbumUrl generates a public URL for an album's artwork. - -Parameters: - - id: The album's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getalbumurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_track_url(id: str, size: int) -> str: - """GetTrackUrl generates a public URL for a track's artwork. - -Parameters: - - id: The track's (media file) unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_gettrackurl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") - - -def artwork_get_playlist_url(id: str, size: int) -> str: - """GetPlaylistUrl generates a public URL for a playlist's artwork. - -Parameters: - - id: The playlist's unique identifier - - size: Desired image size in pixels (0 for original size) - -Returns the public URL for the artwork, or an error if generation fails. - - Args: - id: str parameter. - size: int parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - "size": size, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _artwork_getplaylisturl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("url", "") diff --git a/plugins/pdk/python/host/nd_host_cache.py b/plugins/pdk/python/host/nd_host_cache.py deleted file mode 100644 index b24e983cc..000000000 --- a/plugins/pdk/python/host/nd_host_cache.py +++ /dev/null @@ -1,448 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Cache host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "cache_setstring") -def _cache_setstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getstring") -def _cache_getstring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setint") -def _cache_setint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getint") -def _cache_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setfloat") -def _cache_setfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getfloat") -def _cache_getfloat(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_setbytes") -def _cache_setbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_getbytes") -def _cache_getbytes(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_has") -def _cache_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "cache_remove") -def _cache_remove(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class CacheGetStringResult: - """Result type for cache_get_string.""" - value: str - exists: bool - - -@dataclass -class CacheGetIntResult: - """Result type for cache_get_int.""" - value: int - exists: bool - - -@dataclass -class CacheGetFloatResult: - """Result type for cache_get_float.""" - value: float - exists: bool - - -@dataclass -class CacheGetBytesResult: - """Result type for cache_get_bytes.""" - value: bytes - exists: bool - - -def cache_set_string(key: str, value: str, ttl_seconds: int) -> None: - """SetString stores a string value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The string value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: str parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_string(key: str) -> CacheGetStringResult: - """GetString retrieves a string value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a string, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetStringResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getstring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetStringResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def cache_set_int(key: str, value: int, ttl_seconds: int) -> None: - """SetInt stores an integer value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The integer value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: int parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_int(key: str) -> CacheGetIntResult: - """GetInt retrieves an integer value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def cache_set_float(key: str, value: float, ttl_seconds: int) -> None: - """SetFloat stores a float value in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The float value to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: float parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": value, - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_float(key: str) -> CacheGetFloatResult: - """GetFloat retrieves a float value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a float, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetFloatResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getfloat(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetFloatResult( - value=response.get("value", 0.0), - exists=response.get("exists", False), - ) - - -def cache_set_bytes(key: str, value: bytes, ttl_seconds: int) -> None: - """SetBytes stores a byte slice in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - -Returns an error if the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_setbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def cache_get_bytes(key: str) -> CacheGetBytesResult: - """GetBytes retrieves a byte slice from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns the value and whether the key exists. If the key doesn't exist -or the stored value is not a byte slice, exists will be false. - - Args: - key: str parameter. - - Returns: - CacheGetBytesResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_getbytes(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return CacheGetBytesResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def cache_has(key: str) -> bool: - """Has checks if a key exists in the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns true if the key exists and has not expired. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def cache_remove(key: str) -> None: - """Remove deletes a value from the cache. - -Parameters: - - key: The cache key (will be namespaced with plugin ID) - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _cache_remove(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_config.py b/plugins/pdk/python/host/nd_host_config.py deleted file mode 100644 index 1dab2fe0e..000000000 --- a/plugins/pdk/python/host/nd_host_config.py +++ /dev/null @@ -1,145 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Config host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "config_get") -def _config_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_getint") -def _config_getint(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "config_keys") -def _config_keys(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class ConfigGetResult: - """Result type for config_get.""" - value: str - exists: bool - - -@dataclass -class ConfigGetIntResult: - """Result type for config_get_int.""" - value: int - exists: bool - - -def config_get(key: str) -> ConfigGetResult: - """Get retrieves a configuration value as a string. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - ConfigGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetResult( - value=response.get("value", ""), - exists=response.get("exists", False), - ) - - -def config_get_int(key: str) -> ConfigGetIntResult: - """GetInt retrieves a configuration value as an integer. - -Parameters: - - key: The configuration key - -Returns the value and whether the key exists. If the key exists but the -value cannot be parsed as an integer, exists will be false. - - Args: - key: str parameter. - - Returns: - ConfigGetIntResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_getint(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return ConfigGetIntResult( - value=response.get("value", 0), - exists=response.get("exists", False), - ) - - -def config_keys(prefix: str) -> Any: - """Keys returns configuration keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by. If empty, returns all keys. - -Returns a sorted slice of matching configuration keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _config_keys(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - return response.get("keys", None) diff --git a/plugins/pdk/python/host/nd_host_http.py b/plugins/pdk/python/host/nd_host_http.py deleted file mode 100644 index a806c8456..000000000 --- a/plugins/pdk/python/host/nd_host_http.py +++ /dev/null @@ -1,60 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as Go errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_httpclient.py b/plugins/pdk/python/host/nd_host_httpclient.py deleted file mode 100644 index c6bfb77c0..000000000 --- a/plugins/pdk/python/host/nd_host_httpclient.py +++ /dev/null @@ -1,59 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the HTTP host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def http_send(request: Any) -> Any: - """Send executes an HTTP request and returns the response. - -Parameters: - - request: The HTTP request to execute, including method, URL, headers, body, and timeout - -Returns the HTTP response with status code, headers, and body. -Network errors, timeouts, and permission failures are returned as errors. -Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - - Args: - request: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "request": request, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _http_send(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_kvstore.py b/plugins/pdk/python/host/nd_host_kvstore.py deleted file mode 100644 index 33eaffc52..000000000 --- a/plugins/pdk/python/host/nd_host_kvstore.py +++ /dev/null @@ -1,362 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the KVStore host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "kvstore_set") -def _kvstore_set(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_setwithttl") -def _kvstore_setwithttl(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_get") -def _kvstore_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getmany") -def _kvstore_getmany(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_has") -def _kvstore_has(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_list") -def _kvstore_list(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_delete") -def _kvstore_delete(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_deletebyprefix") -def _kvstore_deletebyprefix(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "kvstore_getstorageused") -def _kvstore_getstorageused(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class KVStoreGetResult: - """Result type for kvstore_get.""" - value: bytes - exists: bool - - -def kvstore_set(key: str, value: bytes) -> None: - """Set stores a byte value with the given key. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_set(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None: - """SetWithTTL stores a byte value with the given key and a time-to-live. - -After ttlSeconds, the key is treated as non-existent and will be -cleaned up lazily. ttlSeconds must be greater than 0. - -Parameters: - - key: The storage key (max 256 bytes, UTF-8) - - value: The byte slice to store - - ttlSeconds: Time-to-live in seconds (must be > 0) - -Returns an error if the storage limit would be exceeded or the operation fails. - - Args: - key: str parameter. - value: bytes parameter. - ttl_seconds: int parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - "value": base64.b64encode(value).decode("ascii"), - "ttlSeconds": ttl_seconds, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_setwithttl(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_get(key: str) -> KVStoreGetResult: - """Get retrieves a byte value from storage. - -Parameters: - - key: The storage key - -Returns the value and whether the key exists. - - Args: - key: str parameter. - - Returns: - KVStoreGetResult containing value, exists,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return KVStoreGetResult( - value=base64.b64decode(response.get("value", "")), - exists=response.get("exists", False), - ) - - -def kvstore_get_many(keys: Any) -> Any: - """GetMany retrieves multiple values in a single call. - -Parameters: - - keys: The storage keys to retrieve - -Returns a map of key to value for keys that exist and have not expired. -Missing or expired keys are omitted from the result. - - Args: - keys: Any parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "keys": keys, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getmany(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("values", None) - - -def kvstore_has(key: str) -> bool: - """Has checks if a key exists in storage. - -Parameters: - - key: The storage key - -Returns true if the key exists. - - Args: - key: str parameter. - - Returns: - bool: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_has(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("exists", False) - - -def kvstore_list(prefix: str) -> Any: - """List returns all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to filter by (empty string returns all keys) - -Returns a slice of matching keys. - - Args: - prefix: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_list(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("keys", None) - - -def kvstore_delete(key: str) -> None: - """Delete removes a value from storage. - -Parameters: - - key: The storage key - -Returns an error if the operation fails. Does not return an error if the key doesn't exist. - - Args: - key: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "key": key, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_delete(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def kvstore_delete_by_prefix(prefix: str) -> int: - """DeleteByPrefix removes all keys matching the given prefix. - -Parameters: - - prefix: Key prefix to match (must not be empty) - -Returns the number of keys deleted. Includes expired keys. - - Args: - prefix: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "prefix": prefix, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_deletebyprefix(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("deletedCount", 0) - - -def kvstore_get_storage_used() -> int: - """GetStorageUsed returns the total storage used by this plugin in bytes. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _kvstore_getstorageused(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("bytes", 0) diff --git a/plugins/pdk/python/host/nd_host_library.py b/plugins/pdk/python/host/nd_host_library.py deleted file mode 100644 index 12e1bc4eb..000000000 --- a/plugins/pdk/python/host/nd_host_library.py +++ /dev/null @@ -1,86 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Library host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "library_getlibrary") -def _library_getlibrary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "library_getalllibraries") -def _library_getalllibraries(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def library_get_library(id: int) -> Any: - """GetLibrary retrieves metadata for a specific library by ID. - -Parameters: - - id: The library's unique identifier - -Returns the library metadata, or an error if the library is not found. - - Args: - id: int parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "id": id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getlibrary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def library_get_all_libraries() -> Any: - """GetAllLibraries retrieves metadata for all configured libraries. - -Returns a slice of all libraries with their metadata. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _library_getalllibraries(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_scheduler.py b/plugins/pdk/python/host/nd_host_scheduler.py deleted file mode 100644 index 7f0d19241..000000000 --- a/plugins/pdk/python/host/nd_host_scheduler.py +++ /dev/null @@ -1,143 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Scheduler host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "scheduler_scheduleonetime") -def _scheduler_scheduleonetime(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_schedulerecurring") -def _scheduler_schedulerecurring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "scheduler_cancelschedule") -def _scheduler_cancelschedule(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_id: str) -> str: - """ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - delaySeconds: Number of seconds to wait before triggering the event - - payload: Data to be passed to the scheduled event handler - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - delay_seconds: int parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "delaySeconds": delay_seconds, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_scheduleonetime(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str: - """ScheduleRecurring schedules a recurring event using a cron expression. -Plugins that use this function must also implement the SchedulerCallback capability - -Parameters: - - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) - - payload: Data to be passed to each scheduled event handler invocation - - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - -Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - - Args: - cron_expression: str parameter. - payload: str parameter. - schedule_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "cronExpression": cron_expression, - "payload": payload, - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_schedulerecurring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newScheduleId", "") - - -def scheduler_cancel_schedule(schedule_id: str) -> None: - """CancelSchedule cancels a scheduled job identified by its schedule ID. - -This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -any future events. - -Returns an error if the schedule ID is not found or if cancellation fails. - - Args: - schedule_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "scheduleId": schedule_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_cancelschedule(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py deleted file mode 100644 index cf35bc043..000000000 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ /dev/null @@ -1,101 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the SubsonicAPI host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "subsonicapi_callraw") -def _subsonicapi_callraw(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@dataclass -class SubsonicAPICallRawResult: - """Result type for subsonicapi_call_raw.""" - content_type: str - data: bytes - - -def subsonicapi_call(uri: str) -> str: - """Call executes a Subsonic API request and returns the JSON response. - -The uri parameter should be the Subsonic API path without the server prefix, -e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. - - Args: - uri: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_call(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("responseJson", "") - - -def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult: - """CallRaw executes a Subsonic API request and returns the raw binary response. -Designed for binary endpoints like getCoverArt and stream that return -non-JSON data. The data is base64-encoded over JSON on the wire. - - Args: - uri: str parameter. - - Returns: - SubsonicAPICallRawResult containing content_type, data,. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "uri": uri, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_callraw(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return SubsonicAPICallRawResult( - content_type=response.get("contentType", ""), - data=base64.b64decode(response.get("data", "")), - ) diff --git a/plugins/pdk/python/host/nd_host_task.py b/plugins/pdk/python/host/nd_host_task.py deleted file mode 100644 index 5d6e7474c..000000000 --- a/plugins/pdk/python/host/nd_host_task.py +++ /dev/null @@ -1,188 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Task host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "task_createqueue") -def _task_createqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_enqueue") -def _task_enqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_get") -def _task_get(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_cancel") -def _task_cancel(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "task_clearqueue") -def _task_clearqueue(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def task_create_queue(name: str, config: Any) -> None: - """CreateQueue creates a named task queue with the given configuration. -Zero-value fields in config use sensible defaults. -If a queue with the same name already exists, returns an error. -On startup, this also recovers any stale "running" tasks from a previous crash. - - Args: - name: str parameter. - config: Any parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "name": name, - "config": config, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_createqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_enqueue(queue_name: str, payload: bytes) -> str: - """Enqueue adds a task to the named queue. Returns the task ID. -payload is opaque bytes passed back to the plugin on execution. - - Args: - queue_name: str parameter. - payload: bytes parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - "payload": base64.b64encode(payload).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_enqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", "") - - -def task_get(task_id: str) -> Any: - """Get returns the current state of a task including its status, -message, and attempt count. - - Args: - task_id: str parameter. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_get(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def task_cancel(task_id: str) -> None: - """Cancel cancels a pending task. Returns error if already -running, completed, or failed. - - Args: - task_id: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "taskId": task_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_cancel(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def task_clear_queue(queue_name: str) -> int: - """ClearQueue removes all pending tasks from the named queue. -Running tasks are not affected. Returns the number of tasks removed. - - Args: - queue_name: str parameter. - - Returns: - int: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "queueName": queue_name, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _task_clearqueue(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", 0) diff --git a/plugins/pdk/python/host/nd_host_users.py b/plugins/pdk/python/host/nd_host_users.py deleted file mode 100644 index a325156a7..000000000 --- a/plugins/pdk/python/host/nd_host_users.py +++ /dev/null @@ -1,80 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the Users host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "users_getusers") -def _users_getusers(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "users_getadmins") -def _users_getadmins(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def users_get_users() -> Any: - """GetUsers returns all users the plugin has been granted access to. -Only minimal user information (userName, name, isAdmin) is returned. -Sensitive fields like password and email are never exposed. - -Returns a slice of users the plugin can access, or an empty slice if none configured. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getusers(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) - - -def users_get_admins() -> Any: - """GetAdmins returns only admin users the plugin has been granted access to. -This is a convenience method that filters GetUsers results to include only admins. - -Returns a slice of admin users the plugin can access, or an empty slice if none. - - Returns: - Any: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request_bytes = b"{}" - request_mem = extism.memory.alloc(request_bytes) - response_offset = _users_getadmins(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("result", None) diff --git a/plugins/pdk/python/host/nd_host_websocket.py b/plugins/pdk/python/host/nd_host_websocket.py deleted file mode 100644 index 4e882914c..000000000 --- a/plugins/pdk/python/host/nd_host_websocket.py +++ /dev/null @@ -1,182 +0,0 @@ -# Code generated by ndpgen. DO NOT EDIT. -# -# This file contains client wrappers for the WebSocket host service. -# It is intended for use in Navidrome plugins built with extism-py. -# -# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. -# The @extism.import_fn decorators are only detected when defined in the plugin's -# main __init__.py file. Copy the needed functions from this file into your plugin. - -from dataclasses import dataclass -from typing import Any - -import extism -import json -import base64 - - -class HostFunctionError(Exception): - """Raised when a host function returns an error.""" - pass - - -@extism.import_fn("extism:host/user", "websocket_connect") -def _websocket_connect(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendtext") -def _websocket_sendtext(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_sendbinary") -def _websocket_sendbinary(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "websocket_closeconnection") -def _websocket_closeconnection(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -def websocket_connect(url: str, headers: Any, connection_id: str) -> str: - """Connect establishes a WebSocket connection to the specified URL. - -Plugins that use this function must also implement the WebSocketCallback capability -to receive incoming messages and connection events. - -Parameters: - - url: The WebSocket URL to connect to (ws:// or wss://) - - headers: Optional HTTP headers to include in the handshake request - - connectionID: Optional unique identifier for the connection. If empty, one will be generated - -Returns the connection ID that can be used to send messages or close the connection, -or an error if the connection fails. - - Args: - url: str parameter. - headers: Any parameter. - connection_id: str parameter. - - Returns: - str: The result value. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "url": url, - "headers": headers, - "connectionId": connection_id, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_connect(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - return response.get("newConnectionId", "") - - -def websocket_send_text(connection_id: str, message: str) -> None: - """SendText sends a text message over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - message: The text message to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - message: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "message": message, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendtext(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_send_binary(connection_id: str, data: bytes) -> None: - """SendBinary sends binary data over an established WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - data: The binary data to send - -Returns an error if the connection is not found or if sending fails. - - Args: - connection_id: str parameter. - data: bytes parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "data": base64.b64encode(data).decode("ascii"), - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_sendbinary(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - - - -def websocket_close_connection(connection_id: str, code: int, reason: str) -> None: - """CloseConnection gracefully closes a WebSocket connection. - -Parameters: - - connectionID: The connection identifier returned by Connect - - code: WebSocket close status code (e.g., 1000 for normal closure) - - reason: Optional human-readable reason for closing - -Returns an error if the connection is not found or if closing fails. - - Args: - connection_id: str parameter. - code: int parameter. - reason: str parameter. - - Raises: - HostFunctionError: If the host function returns an error. - """ - request = { - "connectionId": connection_id, - "code": code, - "reason": reason, - } - request_bytes = json.dumps(request).encode("utf-8") - request_mem = extism.memory.alloc(request_bytes) - response_offset = _websocket_closeconnection(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise HostFunctionError(response["error"]) - diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml index 443f19da5..e9fe367da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" crate-type = ["rlib"] [dependencies] +nd-pdk-types = { path = "../nd-pdk-types" } base64 = "0.22" extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index b0361a3a3..d3d2b19aa 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -5,6 +5,8 @@ //! This crate provides type definitions, traits, and registration macros //! for implementing Navidrome plugin capabilities in Rust. +pub use nd_pdk_types as types; + pub mod lifecycle; pub mod lyrics; pub mod metadata; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 0a861a1ed..a8449a5ec 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -18,20 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// GetLyricsRequest contains the track information for lyrics lookup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -77,10 +66,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec, + pub album_artists: Vec, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 463e52c37..38fcae9da 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -18,6 +18,12 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -92,20 +98,6 @@ pub struct ArtistMBIDResponse { #[serde(default)] pub mbid: String, } -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// ArtistRequest is the common request for artist-related functions. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -162,7 +154,7 @@ pub struct SimilarArtistsRequest { pub struct SimilarArtistsResponse { /// Artists is the list of similar artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, } /// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -227,39 +219,7 @@ pub struct SimilarSongsByTrackRequest { pub struct SimilarSongsResponse { /// Songs is the list of similar songs. #[serde(default)] - pub songs: Vec, -} -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, + pub songs: Vec, } /// TopSongsRequest is the request for GetArtistTopSongs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -284,7 +244,7 @@ pub struct TopSongsRequest { pub struct TopSongsResponse { /// Songs is the list of top songs. #[serde(default)] - pub songs: Vec, + pub songs: Vec, } /// Error represents an error from a capability method. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 1e9c51375..b0b843e6c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -18,6 +18,9 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; /// ScrobblerError represents an error type for scrobbling operations. pub type ScrobblerError = &'static str; /// ScrobblerErrorNotAuthorized indicates the user is not authorized. @@ -26,20 +29,6 @@ pub const SCROBBLER_ERROR_NOT_AUTHORIZED: ScrobblerError = "scrobbler(not_author pub const SCROBBLER_ERROR_RETRY_LATER: ScrobblerError = "scrobbler(retry_later)"; /// ScrobblerErrorUnrecoverable indicates an unrecoverable error. pub const SCROBBLER_ERROR_UNRECOVERABLE: ScrobblerError = "scrobbler(unrecoverable)"; -/// ArtistRef is a reference to an artist with name and optional MBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} /// IsAuthorizedRequest is the request for authorization check. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -126,10 +115,10 @@ pub struct TrackInfo { pub album_artist: String, /// Artists is the list of track artists. #[serde(default)] - pub artists: Vec, + pub artists: Vec, /// AlbumArtists is the list of album artists. #[serde(default)] - pub album_artists: Vec, + pub album_artists: Vec, /// Duration is the track duration in seconds. #[serde(default)] pub duration: f32, diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs index eb2868929..a28df9c0a 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -18,14 +18,20 @@ fn is_zero_u64(value: &u64) -> bool { *value == 0 } fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } #[allow(dead_code)] fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } + +#[deprecated(note = "use nd_pdk::types::ArtistRef")] +pub type ArtistRef = nd_pdk_types::ArtistRef; + +#[deprecated(note = "use nd_pdk::types::SongRef")] +pub type SongRef = nd_pdk_types::SongRef; /// FindSonicPathRequest represents the FindSonicPathRequest data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FindSonicPathRequest { #[serde(default)] - pub start_song: SongRef, + pub start_song: nd_pdk_types::SongRef, #[serde(default)] - pub end_song: SongRef, + pub end_song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } @@ -34,48 +40,16 @@ pub struct FindSonicPathRequest { #[serde(rename_all = "camelCase")] pub struct GetSonicSimilarTracksRequest { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub count: i32, } -/// SongRef is a reference to a song with metadata for matching. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SongRef { - /// ID is the internal Navidrome mediafile ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the song name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// ISRC is the International Standard Recording Code for the song. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub isrc: String, - /// Artist is the artist name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Album is the album name. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album: String, - /// AlbumMBID is the MusicBrainz release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_mbid: String, - /// Duration is the song duration in seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, -} /// SonicMatch represents the SonicMatch data structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SonicMatch { #[serde(default)] - pub song: SongRef, + pub song: nd_pdk_types::SongRef, #[serde(default)] pub similarity: f64, } diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml index 519096110..100ce6a35 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -13,5 +13,6 @@ crate-type = ["rlib"] [dependencies] base64 = "0.22" extism-pdk = "1.2" +nd-pdk-types = { path = "../nd-pdk-types" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3a31bc489..cc1fdc190 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -38,6 +38,7 @@ //! - [`http`] - provides outbound HTTP request capabilities for plugins. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. +//! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -86,6 +87,13 @@ pub mod library { pub use super::nd_host_library::*; } +#[doc(hidden)] +mod nd_host_matcher; +/// resolves externally-obtained songs to local library tracks, +pub mod matcher { + pub use super::nd_host_matcher::*; +} + #[doc(hidden)] mod nd_host_scheduler; /// provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs new file mode 100644 index 000000000..be2819257 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs @@ -0,0 +1,65 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// MatchOptions carries optional parameters for a match request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchOptions { + #[serde(default)] + pub username: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsRequest { + songs: Vec, + opts: MatchOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsResponse { + #[serde(default)] + results: Vec>, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn matcher_matchsongs(input: Json) -> Json; +} + +/// MatchSongs resolves each input song to its best-matching library track. +/// It returns one entry per input song, in the same order as the input; the +/// entry for an input song that had no match is empty (absent). Results are +/// limited to the libraries the plugin (and the scoped user, if any) can access. +/// +/// # Arguments +/// * `songs` - Vec parameter. +/// * `opts` - MatchOptions parameter. +/// +/// # Returns +/// The results value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn match_songs(songs: Vec, opts: MatchOptions) -> Result>, Error> { + let response = unsafe { + matcher_matchsongs(Json(MatcherMatchSongsRequest { + songs: songs, + opts: opts, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.results) +} diff --git a/plugins/pdk/rust/nd-pdk-types/Cargo.toml b/plugins/pdk/rust/nd-pdk-types/Cargo.toml new file mode 100644 index 000000000..e20e07021 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nd-pdk-types" +version = "0.1.0" +edition = "2021" +description = "Navidrome shared plugin data types for Rust" +authors = ["Navidrome Team"] +license = "GPL-3.0" + +[lib] +path = "src/lib.rs" +crate-type = ["rlib"] + +[dependencies] +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs new file mode 100644 index 000000000..fa1a87048 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-types/src/lib.rs @@ -0,0 +1,239 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +//! Navidrome shared plugin data types. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// ArtistRef is the minimal information a plugin returns for Navidrome to match an +/// artist against the library. It is a reference, not a full artist entity: it +/// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +/// few projection fields used when describing a track's participants, never +/// descriptive data such as biographies or images. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + /// ID is the internal Navidrome artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the artist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// SortName is the artist name used for sorting (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_name: String, + /// Role is the participation category (e.g. "artist", "composer", "performer"). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub role: String, + /// SubRole is a specialization within Role (e.g. the instrument for a performer). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sub_role: String, +} +/// SongRef is the minimal information exchanged between a plugin and Navidrome to +/// match a song. It is used both as input (a song Navidrome already has) and as +/// output (a song a plugin suggests, which may not be in the library yet). Unlike +/// Track, it is an abstract recording reference carrying only matching keys (IDs, +/// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + /// + /// Deprecated: use Artists. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + /// + /// Deprecated: use Artists. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artists: Vec, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + /// + /// Deprecated: use DurationMs, which carries millisecond precision. When + /// DurationMs is non-zero it takes precedence; Duration is kept only for + /// backwards compatibility with plugins that still send seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, + /// DurationMs is the song duration in milliseconds. It supersedes Duration + /// when non-zero. + #[serde(default, skip_serializing_if = "is_zero_u32")] + pub duration_ms: u32, +} +/// Track is a stable, public projection of a library media file for plugin consumption. +/// It is a sane subset of the internal model.MediaFile, intended for reuse across host +/// services and capabilities. Timestamps are Unix epoch seconds. +/// +/// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +/// Track is a concrete library entity: it identifies a specific media file that exists +/// (or once existed) in the library and exposes its full descriptive metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Track { + /// Identity & location + #[serde(default)] + pub id: String, + #[serde(default)] + pub library_id: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub library_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, + #[serde(default)] + pub missing: bool, + /// Core metadata + #[serde(default)] + pub title: String, + #[serde(default)] + pub album: String, + #[serde(default)] + pub artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_title: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_album_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_artist_name: String, + /// Track / disc / dates + #[serde(default)] + pub track_number: i32, + #[serde(default)] + pub disc_number: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub disc_subtitle: String, + #[serde(default)] + pub year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub date: String, + #[serde(default)] + pub original_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub original_date: String, + #[serde(default)] + pub release_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub release_date: String, + /// Audio / file + #[serde(default)] + pub size: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub suffix: String, + #[serde(default)] + pub duration: f64, + #[serde(default)] + pub bit_rate: i32, + #[serde(default)] + pub sample_rate: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bit_depth: Option, + #[serde(default)] + pub channels: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub codec: String, + /// Descriptive + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub genres: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub comment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bpm: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub explicit_status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub catalog_num: String, + #[serde(default)] + pub compilation: bool, + #[serde(default)] + pub has_cover_art: bool, + /// MusicBrainz + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_recording_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_track_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_group_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_comment: String, + /// ReplayGain — nil means no data; 0 is a valid measured value, so these + /// must stay pointers to distinguish "absent" from "0". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_gain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_peak: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_gain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_peak: Option, + /// Timestamps (Unix epoch seconds) + #[serde(default)] + pub birth_time: i64, + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub updated_at: i64, + /// AverageRating is the track's mean rating across all users (always set; 0 when unrated). + #[serde(default)] + pub average_rating: f64, + /// Per-user annotations, set only for a user-scoped match. Timestamps are Unix + /// seconds; a nil pointer means "no value". + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub starred: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub starred_at: Option, + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub rating: i32, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub play_count: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub play_date: Option, + /// Composite + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tags: std::collections::HashMap>, + /// Participants lists the track's artists across all roles, each tagged with its Role. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub participants: Vec, +} diff --git a/plugins/pdk/rust/nd-pdk/Cargo.toml b/plugins/pdk/rust/nd-pdk/Cargo.toml index 34fe9f032..8ca457914 100644 --- a/plugins/pdk/rust/nd-pdk/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["rlib"] [dependencies] nd-pdk-host = { path = "../nd-pdk-host" } nd-pdk-capabilities = { path = "../nd-pdk-capabilities" } +nd-pdk-types = { path = "../nd-pdk-types" } extism-pdk = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk/src/lib.rs b/plugins/pdk/rust/nd-pdk/src/lib.rs index b1389938b..36b2ec316 100644 --- a/plugins/pdk/rust/nd-pdk/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk/src/lib.rs @@ -31,5 +31,8 @@ pub use nd_pdk_host as host; /// Capability wrappers for implementing plugin exports. pub use nd_pdk_capabilities::*; +/// Shared plugin data types. +pub use nd_pdk_types as types; + /// Re-export extism-pdk for convenience. pub use extism_pdk; diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 1799ba3ce..bb081988e 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -48,7 +48,7 @@ func TestPlugins(t *testing.T) { // Set CacheFolder globally so all tests (including those using // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = sharedCacheDir + conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) @@ -126,7 +126,7 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugins diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 8abdccf07..b3203a352 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) // CapabilityScrobbler indicates the plugin can receive scrobble events. @@ -154,10 +155,10 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef -func participantsToArtistRefs(participants model.ParticipantList) []capabilities.ArtistRef { - refs := make([]capabilities.ArtistRef, len(participants)) +func participantsToArtistRefs(participants model.ParticipantList) []types.ArtistRef { + refs := make([]types.ArtistRef, len(participants)) for i, p := range participants { - refs[i] = capabilities.ArtistRef{ + refs[i] = types.ArtistRef{ ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index ff399680e..a4f7d73f8 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/plugins/types" ) const CapabilitySonicSimilarity Capability = "SonicSimilarity" @@ -61,8 +62,8 @@ func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endM return sonicMatchesToSimilarResults(resp.Matches), nil } -func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { - ref := capabilities.SongRef{ +func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { + ref := types.SongRef{ ID: mf.ID, Name: mf.Title, MBID: mf.MbzRecordingID, @@ -70,7 +71,10 @@ func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { ArtistMBID: mf.MbzArtistID, Album: mf.Album, AlbumMBID: mf.MbzAlbumID, - Duration: mf.Duration, + } + ref.SetDuration(mf.Duration) + for _, p := range mf.Participants[model.RoleArtist] { + ref.Artists = append(ref.Artists, types.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, SortName: p.SortArtistName, Role: model.RoleArtist.String()}) } if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { ref.ISRC = isrcs[0] diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index daea04761..cb08b9acd 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -5,6 +5,7 @@ package plugins import ( "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -42,7 +43,8 @@ var _ = Describe("SonicSimilarityPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) - Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Song.Artists).To(HaveLen(1)) + Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) Expect(results[0].Similarity).To(Equal(1.0)) Expect(results[1].Similarity).To(Equal(0.9)) Expect(results[2].Similarity).To(Equal(0.8)) @@ -67,7 +69,8 @@ var _ = Describe("SonicSimilarityPlugin", Ordered, func() { Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) - Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Song.Artists).To(HaveLen(1)) + Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) Expect(results[0].Similarity).To(Equal(1.0)) Expect(results[1].Similarity).To(Equal(0.95)) Expect(results[2].Similarity).To(Equal(0.9)) @@ -108,3 +111,24 @@ var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { Expect(err.Error()).To(ContainSubstring("simulated plugin error")) }) }) + +var _ = Describe("mediaFileToSongRef multi-artist", func() { + It("fills Artists (with IDs) from role=artist participants", func() { + mf := &model.MediaFile{ID: "x", Title: "Collab", Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-drake", Name: "Drake", MbzArtistID: "m-drake"}}, + {Artist: model.Artist{ID: "ar-future", Name: "Future", MbzArtistID: "m-future"}}, + }, + }} + ref := mediaFileToSongRef(mf) + Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{ + {ID: "ar-drake", Name: "Drake", MBID: "m-drake", Role: "artist"}, + {ID: "ar-future", Name: "Future", MBID: "m-future", Role: "artist"}, + })) + }) + It("leaves Artists nil when the track has no role=artist participants", func() { + mf := &model.MediaFile{ID: "x", Title: "Solo", Artist: "Drake"} + ref := mediaFileToSongRef(mf) + Expect(ref.Artists).To(BeNil()) + }) +}) diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 0e485ceba..2ee2dabbf 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -15,12 +15,47 @@ func init() { type testLyrics struct{} func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { - // Check for configured error errMsg, hasErr := pdk.GetConfig("error") if hasErr && errMsg != "" { return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. + format, hasFormat := pdk.GetConfig("format") + if hasFormat { + var text string + var lang string + switch format { + case "ttml": + lang = "eng" + text = ` + + +
+

plugin ttml line

+
+ +
` + case "srt": + lang = "eng" + text = "1\n00:00:01,000 --> 00:00:02,000\nplugin srt line\n" + case "yaml": + lang = "eng" + text = "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: \"plugin yaml line\"\n start_ms: 1000\n" + case "lrc": + lang = "eng" + text = "[00:01.00]plugin lrc line" + case "plain": + lang = "eng" + text = "plugin plain line" + } + if text != "" { + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, + }, nil + } + } + // Check if we should omit language (to test default language handling) noLang, hasNoLang := pdk.GetConfig("no_lang") lang := "eng" diff --git a/plugins/testdata/test-matcher/go.mod b/plugins/testdata/test-matcher/go.mod new file mode 100644 index 000000000..7abbc93c6 --- /dev/null +++ b/plugins/testdata/test-matcher/go.mod @@ -0,0 +1,16 @@ +module test-matcher + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-matcher/go.sum b/plugins/testdata/test-matcher/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-matcher/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-matcher/main.go b/plugins/testdata/test-matcher/main.go new file mode 100644 index 000000000..80d650b7b --- /dev/null +++ b/plugins/testdata/test-matcher/main.go @@ -0,0 +1,56 @@ +// Test Matcher plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-matcher.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" +) + +// TestMatcherInput is the input for the nd_test_matcher callback. +type TestMatcherInput struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` +} + +// TestMatcherOutput is the output from the nd_test_matcher callback. +// MatchedIDs and Starred are aligned to the input: an empty string at index i +// means no match; Starred[i] reflects the matched track's starred flag. +type TestMatcherOutput struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` +} + +// nd_test_matcher forwards the input song list to the host matcher and returns matched track IDs. +// +//go:wasmexport nd_test_matcher +func ndTestMatcher() int32 { + var input TestMatcherInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + results, err := host.MatcherMatchSongs(input.Songs, host.MatchOptions{Username: input.Username}) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + ids := make([]string, len(results)) + starred := make([]bool, len(results)) + for i, t := range results { + if t != nil { + ids[i] = t.ID + starred[i] = t.Starred + } + } + pdk.OutputJSON(TestMatcherOutput{MatchedIDs: ids, Starred: starred}) + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-matcher/manifest.json b/plugins/testdata/test-matcher/manifest.json new file mode 100644 index 000000000..26be5c577 --- /dev/null +++ b/plugins/testdata/test-matcher/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Matcher Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for Matcher integration testing", + "permissions": { + "matcher": { + "reason": "For testing matcher operations" + }, + "library": { + "reason": "Matching returns library tracks" + } + } +} diff --git a/plugins/testdata/test-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go index 23e933eb3..bb8a092c6 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/metadata" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -67,9 +68,9 @@ func (t *testMetadataAgent) GetSimilarArtists(input metadata.SimilarArtistsReque if limit == 0 { limit = 5 } - artists := make([]metadata.ArtistRef, 0, limit) + artists := make([]types.ArtistRef, 0, limit) for i := range limit { - artists = append(artists, metadata.ArtistRef{ + artists = append(artists, types.ArtistRef{ ID: "similar-artist-id-" + strconv.Itoa(i+1), Name: input.Name + " Similar " + string(rune('A'+i)), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -86,9 +87,9 @@ func (t *testMetadataAgent) GetArtistTopSongs(input metadata.TopSongsRequest) (* if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "song-id-" + strconv.Itoa(i+1), Name: input.Name + " Song " + strconv.Itoa(i+1), MBID: "song-mbid-" + strconv.Itoa(i+1), @@ -128,9 +129,9 @@ func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "similar-track-id-" + strconv.Itoa(i+1), Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -150,9 +151,9 @@ func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "album-similar-id-" + strconv.Itoa(i+1), Name: "Album Similar #" + strconv.Itoa(i+1), Artist: input.Artist, @@ -170,9 +171,9 @@ func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsB if count == 0 { count = 5 } - songs := make([]metadata.SongRef, 0, count) + songs := make([]types.SongRef, 0, count) for i := range count { - songs = append(songs, metadata.SongRef{ + songs = append(songs, types.SongRef{ ID: "artist-similar-id-" + strconv.Itoa(i+1), Name: input.Name + " Style Song #" + strconv.Itoa(i+1), Artist: input.Name + " Similar Artist", diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go index e315bff15..538e9c8f3 100644 --- a/plugins/testdata/test-sonic-similarity/main.go +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" + "github.com/navidrome/navidrome/plugins/pdk/go/types" ) func init() { @@ -35,7 +36,7 @@ func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSon matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "similar-track-" + strconv.Itoa(i+1), Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), Artist: input.Song.Artist, @@ -57,7 +58,7 @@ func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathR matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: sonicsimilarity.SongRef{ + Song: types.SongRef{ ID: "path-track-" + strconv.Itoa(i+1), Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), Artist: input.StartSong.Artist, diff --git a/plugins/types/track.go b/plugins/types/track.go new file mode 100644 index 000000000..9b63c0f26 --- /dev/null +++ b/plugins/types/track.go @@ -0,0 +1,93 @@ +package types + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` +} diff --git a/plugins/types/types.go b/plugins/types/types.go new file mode 100644 index 000000000..71928a924 --- /dev/null +++ b/plugins/types/types.go @@ -0,0 +1,87 @@ +package types + +// ArtistRef is the minimal information a plugin returns for Navidrome to match an +// artist against the library. It is a reference, not a full artist entity: it +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never +// descriptive data such as biographies or images. +type ArtistRef struct { + // ID is the internal Navidrome artist ID (if known). + ID string `json:"id,omitempty"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the artist. + MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` +} + +// SongRef is the minimal information exchanged between a plugin and Navidrome to +// match a song. It is used both as input (a song Navidrome already has) and as +// output (a song a plugin suggests, which may not be in the library yet). Unlike +// Track, it is an abstract recording reference carrying only matching keys (IDs, +// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` // TODO: rename to Title to align with Track.Title and model.MediaFile.Title; kept as Name for now for compatibility. + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + // + // Deprecated: use Artists. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. + ArtistMBID string `json:"artistMbid,omitempty"` + // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. + Artists []ArtistRef `json:"artists,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. + Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// DurationInMs returns the song duration in milliseconds, preferring the +// millisecond-precision DurationMs and falling back to the deprecated +// seconds-based Duration. It returns 0 when neither is set, and clamps a +// negative seconds value to 0 to avoid an unsigned-conversion wraparound. +func (s SongRef) DurationInMs() uint32 { + if s.DurationMs != 0 { + return s.DurationMs + } + if s.Duration < 0 { + return 0 + } + return uint32(s.Duration * 1000) +} + +// SetDuration sets the song duration from a value in seconds, populating both the +// millisecond-precision DurationMs and the deprecated seconds-based Duration so +// that plugins reading either field see a consistent value. Use this when +// building a SongRef to send to a plugin. +func (s *SongRef) SetDuration(seconds float32) { + s.Duration = seconds + if seconds < 0 { + s.DurationMs = 0 + return + } + s.DurationMs = uint32(seconds * 1000) +} diff --git a/plugins/types/types_suite_test.go b/plugins/types/types_suite_test.go new file mode 100644 index 000000000..9c058ec4f --- /dev/null +++ b/plugins/types/types_suite_test.go @@ -0,0 +1,17 @@ +package types_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTypes(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Types Suite") +} diff --git a/plugins/types/types_test.go b/plugins/types/types_test.go new file mode 100644 index 000000000..ae6d40a25 --- /dev/null +++ b/plugins/types/types_test.go @@ -0,0 +1,50 @@ +package types_test + +import ( + "github.com/navidrome/navidrome/plugins/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SongRef", func() { + Describe("DurationInMs", func() { + It("returns DurationMs when set", func() { + s := types.SongRef{DurationMs: 247333, Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247333))) + }) + + It("falls back to Duration (seconds) when DurationMs is zero", func() { + s := types.SongRef{Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247500))) + }) + + It("returns 0 when neither is set", func() { + Expect(types.SongRef{}.DurationInMs()).To(BeZero()) + }) + + It("clamps a negative seconds value to 0 instead of overflowing", func() { + Expect(types.SongRef{Duration: -1}.DurationInMs()).To(BeZero()) + }) + }) + + Describe("SetDuration", func() { + It("populates both DurationMs and the deprecated Duration from seconds", func() { + var s types.SongRef + s.SetDuration(247.333) + Expect(s.Duration).To(BeNumerically("~", 247.333, 0.001)) + Expect(s.DurationMs).To(Equal(uint32(247333))) + }) + + It("keeps DurationInMs consistent with what was set", func() { + var s types.SongRef + s.SetDuration(60) + Expect(s.DurationInMs()).To(Equal(uint32(60000))) + }) + + It("clamps a negative duration to a zero DurationMs", func() { + var s types.SongRef + s.SetDuration(-1) + Expect(s.DurationMs).To(BeZero()) + }) + }) +}) diff --git a/release/build-tags.sh b/release/build-tags.sh new file mode 100755 index 000000000..f719117ff --- /dev/null +++ b/release/build-tags.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Print the Go build tags for the xx-cc target platform (used by the Dockerfile). +# +# gen2brain/webp's native libwebp backend links ebitengine/purego, whose reverse +# callbacks are unsupported on 32-bit ARM and x86 and SIGSEGV at package-init time, +# taking the whole process down at startup (issues #5597 / #5606 / #5738). Force the +# WASM-only path there with the "nodynamic" tag; 64-bit arches keep native libwebp. +# +# This is the single source of truth for the tag decision: both Dockerfile build +# stages (Docker-image and standalone downloads) call it so they cannot drift apart. +set -e + +# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the +# script is still correct when run outside the xx environment. Both report the +# cross-compilation target, unlike `uname -m`, which would report the build host. +arch=$(xx-info arch 2>/dev/null || go env GOARCH) + +tags="netgo,sqlite_fts5" +case "${arch}" in + arm | 386) tags="${tags},nodynamic" ;; +esac +printf '%s' "${tags}" diff --git a/release/goreleaser.yml b/release/goreleaser.yml index e5035adda..103f2beaf 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -114,7 +114,7 @@ release: ## Where to go next? * Read installation instructions on our [website](https://www.navidrome.org/docs/installation/). - * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) for a simple cloud solution. + * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution. * Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)! # Add the MSI installers to the release diff --git a/release/verify-binary.sh b/release/verify-binary.sh new file mode 100755 index 000000000..cde775992 --- /dev/null +++ b/release/verify-binary.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Fail the build if a 32-bit ARM/x86 binary links ebitengine/purego, which would +# SIGSEGV at startup on those arches (issue #5738). +# +# Independent safety net for build-tags.sh: it inspects the actual build metadata +# recorded in the binary (survives stripping) instead of trusting the requested +# tags, so it still fires if the tag decision is wrong or gen2brain/webp changes +# its build-tag semantics. Runs in the Dockerfile, where xx-info and go are present. +# +# Usage: verify-binary.sh [...] +set -e + +# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the +# check is still correct when run outside the xx environment. +arch=$(xx-info arch 2>/dev/null || go env GOARCH) + +case "${arch}" in + arm | 386) ;; + *) exit 0 ;; # 64-bit arches legitimately link purego for native libwebp +esac + +for bin in "$@"; do + # Fail loudly if the expected binary is missing (e.g. an unmatched glob), rather + # than letting `go version -m` fail inside the pipeline and silently pass. + if [ ! -f "${bin}" ]; then + echo "ERROR: expected binary '${bin}' not found; purego verification did not run." + exit 1 + fi + if go version -m "${bin}" | grep -q "ebitengine/purego"; then + echo "ERROR: 32-bit binary '${bin}' links ebitengine/purego; it will SIGSEGV at startup (issue #5738)." + echo " Ensure the 'nodynamic' build tag is applied (see release/build-tags.sh)." + exit 1 + fi +done diff --git a/resources/embed.go b/resources/embed.go index 0386e6f79..040bb5d84 100644 --- a/resources/embed.go +++ b/resources/embed.go @@ -16,6 +16,6 @@ var embedFS embed.FS func FS() fs.FS { return merge.FS{ Base: embedFS, - Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), + Overlay: os.DirFS(path.Join(conf.Server.DataFolder.String(), "resources")), } } diff --git a/resources/i18n/de.json b/resources/i18n/de.json index c540dee05..1a516d393 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -38,7 +38,9 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "Disc %{discNumber}" + "disc": "Disc %{discNumber}", + "albumGain": "Album Gain", + "trackGain": "Titel Gain" }, "actions": { "addToQueue": "Später abspielen", diff --git a/resources/i18n/es.json b/resources/i18n/es.json index a018eda3d..555c165d6 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -35,6 +35,8 @@ "rawTags": "Etiquetas sin procesar", "bitDepth": "Profundidad de bits", "sampleRate": "Frecuencia de muestreo", + "albumGain": "Ganancia del álbum", + "trackGain": "Ganancia de pista", "missing": "Faltante", "libraryName": "Biblioteca", "composer": "Compositor", @@ -693,7 +695,7 @@ "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", "serverUptime": "Uptime del servidor", - "serverDown": "OFFLINE", + "serverDown": "DESCONECTADO", "scanType": "Tipo", "status": "Error de escaneo", "elapsedTime": "Tiempo transcurrido", diff --git a/resources/i18n/et.json b/resources/i18n/et.json new file mode 100644 index 000000000..b0131f4ea --- /dev/null +++ b/resources/i18n/et.json @@ -0,0 +1,723 @@ +{ + "languageName": "eesti keel", + "resources": { + "song": { + "name": "Laul |||| Laulud", + "fields": { + "albumArtist": "Albumi esitaja", + "duration": "Kestus", + "trackNumber": "Nr", + "playCount": "Esituskordi", + "title": "Pealkiri", + "artist": "Esitaja", + "album": "Album", + "path": "Faili asukoht", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "size": "Faili suurus", + "updatedAt": "Uuendatud", + "bitRate": "Bitikiirus", + "discSubtitle": "Plaadi alapealkiri", + "starred": "Märgi lemmikuks", + "comment": "Kommentaar", + "rating": "Hinnang", + "quality": "Kvaliteet", + "bpm": "BPM", + "playDate": "Viimati esitatud", + "channels": "Kanaleid", + "createdAt": "Lisamise kuupäev", + "grouping": "Rühmitamine", + "mood": "Meeleolu", + "participants": "Täiendavad osalejad", + "tags": "Täiendavad sildid", + "mappedTags": "Tuvastatud sildid", + "rawTags": "Sildid töötlemata vaates", + "bitDepth": "Bitisügavus", + "sampleRate": "Diskreetmisagedus", + "missing": "Puudub", + "libraryName": "Kogumik", + "composer": "Helilooja", + "disc": "%{discNumber}. plaat", + "albumGain": "Albumikohane esitusvaljuse tundlikkus", + "trackGain": "Rajakohane esitusvaljuse tundlikkus" + }, + "actions": { + "addToQueue": "Esita hiljem", + "playNow": "Esita kohe", + "addToPlaylist": "Lisa esitusloendisse", + "shuffleAll": "Sega kõik", + "download": "Laadi alla", + "playNext": "Esita järgmisena", + "info": "Loo teave", + "showInPlaylist": "Näita esitusloendis", + "instantMix": "Kohene miks" + } + }, + "album": { + "name": "Album |||| Albumid", + "fields": { + "albumArtist": "Albumi esitaja", + "artist": "Esitaja", + "duration": "Kestus", + "songCount": "laulu", + "playCount": "Esituskordi", + "name": "Nimi", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "updatedAt": "Uuendatud", + "comment": "Kommentaar", + "rating": "Hinnangud", + "createdAt": "Lisamise kuupäev", + "size": "Suurus", + "originalDate": "Originaal", + "releaseDate": "Avaldatud", + "releases": "Väljalase ||| Väljalasked", + "released": "Avaldatud", + "recordLabel": "Plaadifirma", + "catalogNum": "Tunnus kataloogides", + "releaseType": "Tüüp", + "grouping": "Grupeerimine", + "media": "Meedium", + "mood": "Meeleolu", + "date": "Salvestuskuupäev", + "missing": "Puudu", + "libraryName": "Kogumik" + }, + "actions": { + "playAll": "Esita", + "playNext": "Esita järgmisena", + "addToQueue": "Esita hiljem", + "shuffle": "Sega lood", + "addToPlaylist": "Lisa esitusloendisse", + "download": "Laadi alla", + "info": "Albumi teave", + "share": "Jaga" + }, + "lists": { + "all": "Kõik", + "random": "Juhuslik", + "recentlyAdded": "Hiljuti lisatud", + "recentlyPlayed": "Hiljuti esitatud", + "mostPlayed": "Enimesitatud", + "starred": "Lemmikud", + "topRated": "Kõrgeima hinnanguga" + } + }, + "artist": { + "name": "Esitaja |||| Esitajad", + "fields": { + "name": "Nimi", + "albumCount": "Albumeid", + "songCount": "Lugusid", + "playCount": "Esituskordi", + "rating": "Hinnang", + "genre": "Žanr", + "size": "Suurus", + "role": "Roll", + "missing": "Puudub" + }, + "roles": { + "albumartist": "Albumi esitaja ||| Albumi esitajad", + "artist": "Esitaja ||| Esitajad", + "composer": "Helilooja ||| Heliloojad", + "conductor": "Dirigent ||| Dirigendid", + "lyricist": "Laulusõnade autor ||| Laulusõnade autorid", + "arranger": "Seade autor ||| Seade autorid", + "producer": "Produtsent ||| Produtsendid", + "director": "Lavastaja ||| Lavastajad", + "engineer": "Helirežissöör ||| Helirežissöörid", + "mixer": "Miksija ||| Miksijad", + "remixer": "Remiksija ||| Remiksijad", + "djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad", + "performer": "Esineja ||| Esinejad", + "maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad" + }, + "actions": { + "shuffle": "Sega", + "radio": "Raadio", + "topSongs": "Populaarsed lood" + } + }, + "user": { + "name": "Kasutaja |||| Kasutajad", + "fields": { + "userName": "Kasutajanimi", + "isAdmin": "On peakasutaja", + "lastLoginAt": "Viimane sisselogimine", + "updatedAt": "Uuendatud", + "name": "Nimi", + "password": "Salasõna", + "createdAt": "Loodud", + "changePassword": "Kas soovid salasõna muuta?", + "currentPassword": "Senine salasõna", + "newPassword": "Uus salasõna", + "token": "Tunnusluba", + "lastAccessAt": "Viimati avatud", + "libraries": "Kogumikud" + }, + "helperTexts": { + "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", + "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikud või jäta vaikimisi väärtuse kasutamiseks tühjaks" + }, + "notifications": { + "created": "Kasutaja on lisatud", + "updated": "Kasutaja andmed on uuendatud", + "deleted": "Kasutaja on kustutatud" + }, + "message": { + "listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.", + "clickHereForToken": "Tunnusloa saamiseks klõpsi siin", + "selectAllLibraries": "Vali kõik kogumikud", + "adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele" + }, + "validation": { + "librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks" + } + }, + "player": { + "name": "Meediaesitaja |||| Meediaesitajad", + "fields": { + "name": "Nimi", + "transcodingId": "Teisendamine", + "maxBitRate": "Maksimaalne bitikiirus", + "client": "Klient", + "userName": "Kasutajanimi", + "lastSeen": "Viimati nähtud", + "reportRealPath": "Teata tegelikust asukohast", + "scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse" + } + }, + "transcoding": { + "name": "Teisendamine |||| Teisendamised", + "fields": { + "name": "Nimi", + "targetFormat": "Sihtvorming", + "defaultBitRate": "Vaikimisi bitikiirus", + "command": "Käsk" + } + }, + "playlist": { + "name": "Esitusloend ||| Esitusloendid", + "fields": { + "name": "Nimi", + "duration": "Kestus", + "ownerName": "Omanik", + "public": "Avalik", + "updatedAt": "Muudetud", + "createdAt": "Loodud", + "songCount": "Lood", + "comment": "Kommentaar", + "sync": "Automaatne import", + "path": "Impordi siit" + }, + "actions": { + "selectPlaylist": "Valo esitusloend:", + "addNewPlaylist": "Loo „%{name}\"“", + "export": "Ekspordi", + "makePublic": "Muuda avalikuks", + "makePrivate": "Muuda privaatseks", + "saveQueue": "Salvesta esitusjärjekord esitusloendina", + "searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...", + "pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi", + "removeFromSelection": "Eemalda valikust" + }, + "message": { + "duplicate_song": "Lisa topeltlood", + "song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?", + "noPlaylistsFound": "Esitusloendeid ei leidu", + "noPlaylists": "Esitusloendeid pole saadaval" + } + }, + "radio": { + "name": "Raadio ||| Raadiod", + "fields": { + "name": "Nimi", + "streamUrl": "Voogedastuse võrguaadress", + "homePageUrl": "Avalehe võrguaadress", + "updatedAt": "Uuendatud", + "createdAt": "Lisatud" + }, + "actions": { + "playNow": "Esita kohe" + } + }, + "share": { + "name": "Jagamine ||| Jagamised", + "fields": { + "username": "Seda jagas", + "url": "Võrguaadress", + "description": "Kirjeldus", + "contents": "Sisu", + "expiresAt": "Aegub", + "lastVisitedAt": "Viimati vaadatud", + "visitCount": "Külastusi", + "format": "Vorming", + "maxBitRate": "Maksimaalne bitikiirus", + "updatedAt": "Muudetud", + "createdAt": "Lisatud", + "downloadable": "Kas lubad allalaadimised?" + } + }, + "missing": { + "name": "Puuduv fail ||| Puuduvad failid", + "fields": { + "path": "Asukoht", + "size": "Suurus", + "updatedAt": "Kadumise aeg", + "libraryName": "Kogumik" + }, + "actions": { + "remove": "Eemalda", + "remove_all": "Eemalda kõik" + }, + "notifications": { + "removed": "Puuduv(ad) fail(id) on eemaldatud" + }, + "empty": "Puuduvaid faile pole" + }, + "library": { + "name": "Kogumik ||| Kogumikud", + "fields": { + "name": "Nimi", + "path": "Asukoht", + "remotePath": "Asukoht kaugseadmes", + "lastScanAt": "Viimane skaneerimine", + "songCount": "Lood", + "albumCount": "Albumid", + "artistCount": "Esitajad", + "totalSongs": "Lood", + "totalAlbums": "Albumid", + "totalArtists": "Esitajad", + "totalFolders": "Kaustad", + "totalFiles": "Failid", + "totalMissingFiles": "Puuduvad failid", + "totalSize": "Kogumaht", + "totalDuration": "Kestus", + "defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele", + "createdAt": "Lisatud", + "updatedAt": "Muudetud" + }, + "sections": { + "basic": "Põhiteave", + "statistics": "Statistika" + }, + "actions": { + "scan": "Skaneeri kogumikku", + "manageUsers": "Halda kasutajate õigusi", + "viewDetails": "Vaata üksikasju", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täismahuline skaneerimine" + }, + "notifications": { + "created": "Kogumiku loomine õnnestus", + "updated": "Kogumiku uuendamine õnnestus", + "deleted": "Kogumiku kustutamine õnnestus", + "scanStarted": "Kogumiku skaneerimine algas", + "scanCompleted": "Kogumiku skaneerimine lõppes", + "quickScanStarted": "Kiirskaneerimine algas", + "fullScanStarted": "Täismahuline skaneerimine algas", + "scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest" + }, + "validation": { + "nameRequired": "Pead sisestama kogumiku nime", + "pathRequired": "Pead sisestama kogumiku asukoha", + "pathNotDirectory": "Kogumiku asukoht peab olema kaust", + "pathNotFound": "Kogumiku asukoha kausta ei leidu", + "pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav", + "pathInvalid": "Vigane kogumiku asukoha kaust" + }, + "messages": { + "deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.", + "scanInProgress": "Skaneerimine on pooleli...", + "noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud" + } + }, + "plugin": { + "name": "Lisamoodul |||| Lisamoodulid", + "fields": { + "id": "Tunnus", + "name": "Nimi", + "description": "Kirjeldus", + "version": "Versioon", + "author": "Autor", + "website": "Veebisait", + "permissions": "Õigused", + "enabled": "Kasutusel", + "status": "Olek", + "path": "Asukoht", + "lastError": "Viga", + "hasError": "Viga", + "updatedAt": "Uuendatud", + "createdAt": "Paigaldatud", + "configKey": "Võti", + "configValue": "Väärtus", + "allUsers": "Luba kõiki kasutajaid", + "selectedUsers": "Valitud kasutajad", + "allLibraries": "Luba kõik kogumikud", + "selectedLibraries": "Valitud kogumikud", + "allowWriteAccess": "Luba kirjutusõigused" + }, + "sections": { + "status": "Olek", + "info": "Lisamooduli teave", + "configuration": "Seadistus", + "manifest": "Manifest", + "usersPermission": "Kasutajate õigused", + "libraryPermission": "Kogumike õigused" + }, + "status": { + "enabled": "Kasutusel", + "disabled": "Pole kasutusel" + }, + "actions": { + "enable": "Võta kasutusele", + "disable": "Eemalda kasutuselt", + "disabledDueToError": "Enne kasutuselevõtmist paranda viga", + "disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad", + "disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud", + "addConfig": "Lisa seadistus", + "rescan": "Skaneeri uuesti" + }, + "notifications": { + "enabled": "Lisamoodul on kasutusel", + "disabled": "Lisamoodul pole kasutusel", + "updated": "Lisamoodul on uuendatud", + "error": "Viga lisamooduli uuendamisel" + }, + "validation": { + "invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus" + }, + "messages": { + "configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.", + "clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust", + "noConfig": "Ühtegi seadistust pole määratud", + "allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.", + "noUsers": "Ühtegi kasutajat pole valitud", + "permissionReason": "Põhjus", + "usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.", + "allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.", + "noLibraries": "Ühtegi kogumikku pole valitud", + "librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.", + "requiredHosts": "Nõutavad hostid", + "configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:", + "schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.", + "allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus." + }, + "placeholders": { + "configKey": "võti", + "configValue": "väärtus" + } + } + }, + "ra": { + "auth": { + "welcome1": "Aitäh, et paigaldasid Navidrome'i!", + "welcome2": "Alustamiseks lisa peakasutaja", + "confirmPassword": "Korda salasõna", + "buttonCreateAdmin": "Lisa peakasutaja", + "auth_check_error": "Jätkamiseks palun logi sisse", + "user_menu": "Profiil", + "username": "Kasutajanimi", + "password": "Salasõna", + "sign_in": "Logi sisse", + "sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti", + "logout": "Logi välja", + "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" + }, + "validation": { + "invalidChars": "Palun kasuta ainult tähti ja numbreid", + "passwordDoesNotMatch": "Salasõnad ei kattu", + "required": "Nõutav", + "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", + "maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi", + "minValue": "Väärtus peab olema vähemalt %{min}", + "maxValue": "Väärtus ei tohi olla enam, kui %{max}", + "number": "Sisend peab olema number", + "email": "Sisend peab korrektne e-posti aadress", + "oneOf": "Väärtus peab olema üks järgnevaist: %{options}", + "regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}", + "unique": "Sisend peab olema unikaalne", + "url": "Sisend peab olema korrektne võrguaadress" + }, + "action": { + "add_filter": "Lisa filter", + "add": "Lisa", + "back": "Mine tagasi", + "bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud", + "cancel": "Katkesta", + "clear_input_value": "Eemalda väärtus", + "clone": "Klooni", + "confirm": "Kinnita", + "create": "Loo", + "delete": "Kustuta", + "edit": "Muuda", + "export": "Ekspordi", + "list": "Loend", + "refresh": "Uuenda andmed", + "remove_filter": "Eemalda see filter", + "remove": "Eemalda", + "save": "Salvesta", + "search": "Otsi", + "show": "Näita", + "sort": "Järjesta", + "undo": "Võta tegevus tagasi", + "expand": "Laienda", + "close": "Sulge", + "open_menu": "Ava menüü", + "close_menu": "Sulge menüü", + "unselect": "Eemalda valik", + "skip": "Jäta vahele", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Jaga", + "download": "Laadi alla" + }, + "boolean": { + "true": "Jah", + "false": "Ei" + }, + "page": { + "create": "Loo %{name}", + "dashboard": "Töölaud", + "edit": "%{name} #%{id}", + "error": "Midagi läks valesti", + "list": "%{name}", + "loading": "Laadin", + "not_found": "Ei leidu", + "show": "%{name} #%{id}", + "empty": "Nimi on veel puudu - %{name}.", + "invite": "Kas sa sooviksid ühe sellise lisada?" + }, + "input": { + "file": { + "upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast." + }, + "image": { + "upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast." + }, + "references": { + "all_missing": "Viitenumbrite andmeid ei leidu.", + "many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.", + "single_missing": "Seotud viide ei tundu enam olema saadaval." + }, + "password": { + "toggle_visible": "Peida salasõna", + "toggle_hidden": "Näita salasõna" + } + }, + "message": { + "about": "Teave", + "are_you_sure": "Kas oled kindel?", + "bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?", + "bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet", + "delete_content": "Kas oled kindel, et soovid selle objekti kustutada?", + "delete_title": "Kustuta %{name} #%{id}", + "details": "Üksikasjad", + "error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.", + "invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi", + "loading": "Leht on just laadimisel, palun oota hetke", + "no": "Ei", + "not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.", + "yes": "Jah", + "unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?" + }, + "navigation": { + "no_results": "Tulemusi ei leidu", + "no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.", + "page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire", + "page_out_from_end": "Viimasest lehest ei saa edasi minna", + "page_out_from_begin": "Esimese lehe ette ei saa minna", + "page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}", + "page_rows_per_page": "Kirjeid lehel:", + "next": "Edasi", + "prev": "Tagasi", + "skip_nav": "Mine sisu juurde" + }, + "notification": { + "updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud", + "created": "Objekt on loodud", + "deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud", + "bad_item": "Vigane objekt", + "item_doesnt_exist": "Objekti pole olemas", + "http_error": "Viga suhtlemisel serveriga", + "data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.", + "i18n_error": "Vastava keele tõlget ei saa laadida", + "canceled": "Tegevus on tühistatud", + "logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.", + "new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kuvatavad veerud", + "layout": "Paigutus", + "grid": "Ruudustik", + "table": "Tabel" + } + }, + "message": { + "note": "MÄRGE", + "transcodingDisabled": "Teisendusseadistuste muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovid muuta või lisada teisendamisega seotud seadistusi, taaskäivita server %{config} valikuga.", + "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese teisendusseadistuste käivitada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult teisendusvalikute muutmiseks.", + "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", + "noPlaylistsAvailable": "Pole saadaval", + "delete_user_title": "Kustuta kasutaja „%{name}“", + "delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?", + "notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud", + "notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli", + "lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse", + "lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud", + "lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja", + "lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud", + "openIn": { + "lastfm": "Ava Last.fm-is", + "musicbrainz": "Ava MusicBrainzis" + }, + "lastfmLink": "Lisateave...", + "listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}", + "listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja", + "listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud", + "downloadOriginalFormat": "Laadi alla algses vormingus", + "shareOriginalFormat": "Jaga algses vormingus", + "shareDialogTitle": "Jaga - %{resource} „%{name}“", + "shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}", + "shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}", + "shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale", + "downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv", + "remove_missing_title": "Eemalda puuduvad failid", + "remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "remove_all_missing_title": "Eemalda kõik puuduvad failid", + "remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "noSimilarSongsFound": "Sarnaseid lugusid ei leidu", + "noTopSongsFound": "Populaarsemaid lugusid ei leidu", + "startingInstantMix": "Laadin kohest miksi...", + "uploadCover": "Laadi kaanepilt üles", + "removeCover": "Eemalda kaanepilt", + "coverUploaded": "Kaanepilt on uuendatud", + "coverRemoved": "Kaanepilt on eemaldatud", + "coverUploadError": "Viga kaanepildi üleslaadimisel", + "coverRemoveError": "Viga kaanepildi eemaldamisel" + }, + "menu": { + "library": "Kogumik", + "settings": "Seadistused", + "version": "Versioon", + "theme": "Kujundus", + "personal": { + "name": "Isiklik", + "options": { + "theme": "Kujundus", + "language": "Keel", + "defaultView": "Vaikimisi vaade", + "desktop_notifications": "Teavitused töölaual", + "lastfmScrobbling": "Kraasi Last.fm-i teenusesse", + "listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse", + "replaygain": "Esitusvaljuse tundlikkuse režiim", + "preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)", + "gain": { + "none": "Pole kasutusel", + "album": "Kasuta albumikohast esitusvaljuse tundlikkust", + "track": "Kasuta lookohast esitusvaljuse tundlikkust" + }, + "lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud" + } + }, + "albumList": "Albumid", + "about": "Rakenduse teave", + "playlists": "Esitusloendid", + "sharedPlaylists": "Jagatud esitusloendid", + "librarySelector": { + "allLibraries": "Kõik kogumikud (%{count})", + "multipleLibraries": "%{selected} / %{total} kogumikest", + "selectLibraries": "Vali kogumikud", + "none": "Puudub" + } + }, + "player": { + "playListsText": "Esitusjärjekord", + "openText": "Ava", + "closeText": "Sulge", + "notContentText": "Muusikat pole", + "clickToPlayText": "Klõpsa esitamiseks", + "clickToPauseText": "Klõpsa peatamiseks", + "nextTrackText": "Järgmine lugu", + "previousTrackText": "Eelmine lugu", + "reloadText": "Laadi uuesti", + "volumeText": "Helivaljus", + "toggleLyricText": "Näita/peida laulusõnad", + "toggleMiniModeText": "Minimeeri", + "destroyText": "Hävita", + "downloadText": "Laadi alla", + "removeAudioListsText": "Kustuta heliloendid", + "clickToDeleteText": "„%{name}“ kustutamiseks klõpsa", + "emptyLyricText": "Laulusõnu pole", + "playModeText": { + "order": "Oma järjekorras", + "orderLoop": "Korda", + "singleLoop": "Korda üks kord", + "shufflePlay": "Sega lood" + } + }, + "about": { + "links": { + "homepage": "Avaleht", + "source": "Lähtekood", + "featureRequests": "Arendusettepanekud", + "lastInsightsCollection": "Viimati kogutud statistika", + "insights": { + "disabled": "Pole kasutusel", + "waiting": "Ootel" + } + }, + "tabs": { + "about": "Teave", + "config": "Seadistus" + }, + "config": { + "configName": "Seadistuse nimi", + "environmentVariable": "Keskkonnamuutuja", + "currentValue": "Praegune väärtus", + "configurationFile": "Seadistusfail", + "exportToml": "Ekspordi seadistused (TOML-failina)", + "exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina", + "exportFailed": "Seadistuse kopeerimine ei õnnestunud", + "devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)", + "devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda", + "downloadToml": "Laadi seadistused alla (TOML-failina)" + } + }, + "activity": { + "title": "Tegevus", + "totalScanned": "Kokku skaneeritud kaustu", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täisskaneerimine", + "serverUptime": "Serveri katkematu tööaeg", + "serverDown": "POLE VÕRGUS", + "scanType": "Tüüp", + "status": "Skaneerimisviga", + "elapsedTime": "Möödunud aeg", + "selectiveScan": "Valikuline" + }, + "help": { + "title": "Navidrome'i kiirklahvid", + "hotkeys": { + "show_help": "Näita seda abiteavet", + "toggle_menu": "Lülita menüü külgriba sisse/välja", + "toggle_play": "Esita / Peata esitus", + "prev_song": "Eelmine lugu", + "next_song": "Järgmine lugu", + "vol_up": "Heli valjemaks", + "vol_down": "Heli vaiksemaks", + "toggle_love": "Lisa see lugu lemmikute hulka", + "current_song": "Mine esitamisel loo juurde" + } + }, + "nowPlaying": { + "title": "Hetkel esitamisel", + "empty": "Mitte midagi pole hetkel esitamisel", + "minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi" + } +} \ No newline at end of file diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 6bfd09d0e..30db91cde 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -2,7 +2,7 @@ "languageName": "Euskara", "resources": { "song": { - "name": "Abestia |||| Abesti", + "name": "Abestia |||| Abestiak", "fields": { "albumArtist": "Albumaren artista", "duration": "Iraupena", @@ -22,6 +22,8 @@ "bitRate": "Bit-tasa", "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", + "albumGain": "Album-irabazia", + "trackGain": "Pista-irabazia", "channels": "Kanalak", "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", @@ -53,7 +55,7 @@ } }, "album": { - "name": "Albuma |||| Album", + "name": "Albuma |||| Albumak", "fields": { "albumArtist": "Albumaren artista", "artist": "Artista", @@ -104,7 +106,7 @@ } }, "artist": { - "name": "Artista |||| Artista", + "name": "Artista |||| Artistak", "fields": { "name": "Izena", "albumCount": "Album kopurua", @@ -117,7 +119,7 @@ "missing": "Ez da aurkitu" }, "roles": { - "albumartist": "Albumeko egilea |||| Albumeko artistak", + "albumartist": "Albumeko artista |||| Albumeko artistak", "artist": "Artista |||| Artistak", "composer": "Konpositorea |||| Konpositoreak", "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", @@ -335,7 +337,7 @@ } }, "plugin": { - "name": "Plugina |||| Plugin", + "name": "Plugina |||| Pluginak", "fields": { "id": "IDa", "name": "Izena", @@ -492,7 +494,7 @@ "input": { "file": { "upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.", - "upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia." + "upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia." }, "image": { "upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.", @@ -537,9 +539,9 @@ "skip_nav": "Joan edukira" }, "notification": { - "updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira", + "updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira", "created": "Elementua sortu da", - "deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.", + "deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.", "bad_item": "Elementu okerra", "item_doesnt_exist": "Elementua ez dago", "http_error": "Errorea zerbitzariarekin komunikatzerakoan", @@ -588,7 +590,7 @@ "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", "openIn": { - "lastfm": "Ikusi Last.fm-n", + "lastfm": "Ikusi Last.fm-en", "musicbrainz": "Ikusi MusicBrainz-en" }, "lastfmLink": "Irakurri gehiago…", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index bbad47bd6..0e6149f87 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,9 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "Levy %{discNumber}" + "disc": "Levy %{discNumber}", + "albumGain": "Albumin äänenvoimakkuus", + "trackGain": "Kappaleen äänenvoimakkuus" }, "actions": { "addToQueue": "Lisää jonoon", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index d62ca2ab2..444998d03 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,9 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "Disco %{discNumber}" + "disc": "Disco %{discNumber}", + "albumGain": "Gañancia de Album", + "trackGain": "Gañancia de Canción" }, "actions": { "addToQueue": "Ao final da cola", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index cdba66663..762ce4ebb 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -37,7 +37,10 @@ "sampleRate": "Sample rate", "missing": "Hilang", "libraryName": "Pustaka", - "composer": "Komposer" + "composer": "Komposer", + "disc": "Disk %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Trek gain" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -353,7 +356,8 @@ "allUsers": "Izinkan semua pengguna", "selectedUsers": "Pengguna yang dipilih", "allLibraries": "Izinkan semua pustaka", - "selectedLibraries": "Pustaka dipilih" + "selectedLibraries": "Pustaka dipilih", + "allowWriteAccess": "Izinkan akses tulis" }, "sections": { "status": "Status", @@ -398,7 +402,8 @@ "librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.", "requiredHosts": "Hosts diperlukan", "configValidationError": "Validasi konfigurasi gagal:", - "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid." + "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid.", + "allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only" }, "placeholders": { "configKey": "key", @@ -588,7 +593,13 @@ "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan", "noTopSongsFound": "Tidak ada lagu teratas ditemukan", - "startingInstantMix": "Memuat Mix Instan..." + "startingInstantMix": "Memuat Mix Instan...", + "uploadCover": "Unggah Sampul", + "removeCover": "Hapus Sampul", + "coverUploaded": "Sampul diperbarui", + "coverRemoved": "Sampul dihapus", + "coverUploadError": "Kesalahan mengunggah sampul", + "coverRemoveError": "Kesalahan menghapus sampul" }, "menu": { "library": "Pustaka", @@ -674,7 +685,8 @@ "exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML", "exportFailed": "Gagal menyalin konfigurasi", "devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)", - "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang" + "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang", + "downloadToml": "Unduh Konfigurasi (TOML)" } }, "activity": { diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 3f638c13c..46c3df9de 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -38,7 +38,9 @@ "missing": "Ontbrekend", "libraryName": "Bibliotheek", "composer": "Componist", - "disc": "Schijf %{discNumber}" + "disc": "Schijf %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Nummer gain" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index d9f29f5d4..b3b3bab2f 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -4,7 +4,7 @@ "song": { "name": "Música |||| Músicas", "fields": { - "albumArtist": "Artista", + "albumArtist": "Artista do Álbum", "duration": "Duração", "trackNumber": "#", "playCount": "Execuções", @@ -57,7 +57,7 @@ "album": { "name": "Álbum |||| Álbuns", "fields": { - "albumArtist": "Artista", + "albumArtist": "Artista do Álbum", "artist": "Artista", "duration": "Duração", "songCount": "Músicas", diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json index af5afade7..f294d1602 100644 --- a/resources/i18n/sk.json +++ b/resources/i18n/sk.json @@ -2,7 +2,7 @@ "languageName": "Slovenčina", "resources": { "song": { - "name": "Skladba |||| Skladieb", + "name": "Skladba |||| Skladby", "fields": { "albumArtist": "Interpret albumu", "duration": "Dĺžka", @@ -10,20 +10,14 @@ "playCount": "Počet prehratí", "title": "Názov", "artist": "Interpret", - "composer": "Skladateľ", "album": "Album", "path": "Cesta k súboru", - "libraryName": "Knižnica", "genre": "Žáner", "compilation": "Kompilácia", "year": "Rok", "size": "Veľkosť súboru", "updatedAt": "Nahrané", "bitRate": "Prenosová rýchlosť", - "bitDepth": "Bitová hĺbka", - "sampleRate": "Vzorkovacia frekvencia", - "channels": "Kanály", - "disc": "Disk %{discNumber}", "discSubtitle": "Podtitul disku", "starred": "Obľúbené", "comment": "Komentár", @@ -31,6 +25,7 @@ "quality": "Kvalita", "bpm": "BPM", "playDate": "Naposledy prehraná skladba", + "channels": "Kanály", "createdAt": "Pridané", "grouping": "Zoskupovanie", "mood": "Nálada", @@ -38,17 +33,24 @@ "tags": "Ďalšie značky", "mappedTags": "Mapované značky", "rawTags": "Nespracované značky", - "missing": "Chýbajúce" + "bitDepth": "Bitová hĺbka", + "sampleRate": "Vzorkovacia frekvencia", + "missing": "Chýbajúce", + "libraryName": "Knižnica", + "composer": "Skladateľ", + "disc": "Disk %{discNumber}", + "albumGain": "Zosilnenie albumu", + "trackGain": "Zosilnenie stopy" }, "actions": { "addToQueue": "Prehrať neskôr", "playNow": "Prehrať teraz", "addToPlaylist": "Pridať do zoznamu skladieb", - "showInPlaylist": "Zobraziť v zozname skladieb", "shuffleAll": "Zamiešať všetko", "download": "Stiahnuť", "playNext": "Prehrať ako ďalšie", "info": "Získať informácie", + "showInPlaylist": "Zobraziť v zozname skladieb", "instantMix": "Okamžitý mix" } }, @@ -60,38 +62,38 @@ "duration": "Dĺžka", "songCount": "Skladby", "playCount": "Počet prehratí", - "size": "Veľkosť", "name": "Názov", - "libraryName": "Knižnica", "genre": "Žáner", "compilation": "Kompilácia", "year": "Rok", - "date": "Dátum záznamu", - "originalDate": "Pôvodné", - "releaseDate": "Vydané", - "releases": "Vydanie |||| Vydania", - "released": "Vydané", "updatedAt": "Aktualizované", "comment": "Komentár", "rating": "Hodnotenie", "createdAt": "Pridané", + "size": "Veľkosť", + "originalDate": "Pôvodné", + "releaseDate": "Vydané", + "releases": "Vydanie |||| Vydania", + "released": "Vydané", "recordLabel": "Štítok", "catalogNum": "Katalógové číslo", "releaseType": "Typ vydania", "grouping": "Zoskupovanie", "media": "Médiá", "mood": "Nálada", - "missing": "Chýbajúce" + "date": "Dátum záznamu", + "missing": "Chýbajúce", + "libraryName": "Knižnica" }, "actions": { "playAll": "Prehrať", "playNext": "Prehrať ako ďalšie", "addToQueue": "Prehrať neskôr", - "share": "Zdieľať", "shuffle": "Zamiešať", "addToPlaylist": "Pridať do zoznamu skladieb", "download": "Stiahnuť", - "info": "Získať informácie" + "info": "Získať informácie", + "share": "Zdieľať" }, "lists": { "all": "Všetko", @@ -109,10 +111,10 @@ "name": "Názov", "albumCount": "Počet albumov", "songCount": "Počet skladieb", - "size": "Veľkosť", "playCount": "Prehrania", "rating": "Hodnotenie", "genre": "Žáner", + "size": "Veľkosť", "role": "Rola", "missing": "Chýbajúci" }, @@ -133,9 +135,9 @@ "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" }, "actions": { - "topSongs": "Najpopulárnejšie skladby", "shuffle": "Zamiešať", - "radio": "Rádio" + "radio": "Rádio", + "topSongs": "Najpopulárnejšie skladby" } }, "user": { @@ -144,7 +146,6 @@ "userName": "Používateľské meno", "isAdmin": "Správca", "lastLoginAt": "Naposledy prihlásený", - "lastAccessAt": "Posledný Prístup", "updatedAt": "Upravený", "name": "Meno", "password": "Heslo", @@ -153,6 +154,7 @@ "currentPassword": "Súčastné heslo", "newPassword": "Nové heslo", "token": "Token", + "lastAccessAt": "Posledný Prístup", "libraries": "Knižnice" }, "helperTexts": { @@ -164,14 +166,14 @@ "updated": "Používateľ upravený", "deleted": "Používateľ odstránený" }, - "validation": { - "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" - }, "message": { "listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.", "clickHereForToken": "Kliknite sem pre získanie svojho tokenu", "selectAllLibraries": "Vybrať všetky knižnice", "adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam" + }, + "validation": { + "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" } }, "player": { @@ -214,9 +216,9 @@ "selectPlaylist": "Vybrať zoznam skladieb:", "addNewPlaylist": "Vytvoriť \"%{name}\"", "export": "Export", - "saveQueue": "Uložiť rad do zoznamu skladieb", "makePublic": "Zverejniť", "makePrivate": "Nastaviť ako súkromné", + "saveQueue": "Uložiť rad do zoznamu skladieb", "searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...", "pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb", "removeFromSelection": "Odstrániť z výberu" @@ -247,7 +249,6 @@ "username": "Zdieľané", "url": "URL", "description": "Popis", - "downloadable": "Povoliť sťahovanie?", "contents": "Obsah", "expiresAt": "Vyprší", "lastVisitedAt": "Naposledy navštívené", @@ -255,19 +256,17 @@ "format": "Formát", "maxBitRate": "Max. Bit Rate", "updatedAt": "Nahrané", - "createdAt": "Vytvorené" - }, - "notifications": {}, - "actions": {} + "createdAt": "Vytvorené", + "downloadable": "Povoliť sťahovanie?" + } }, "missing": { "name": "Chýbajúci súbor |||| Chýbajúce súbory", - "empty": "Žiadne chýbajúce súbory", "fields": { "path": "Cesta", "size": "Veľkosť", - "libraryName": "Knižnica", - "updatedAt": "Zmizol dňa" + "updatedAt": "Zmizol dňa", + "libraryName": "Knižnica" }, "actions": { "remove": "Odstrániť", @@ -275,7 +274,8 @@ }, "notifications": { "removed": "Chýbajúce súbory odstránené" - } + }, + "empty": "Žiadne chýbajúce súbory" }, "library": { "name": "Knižnica |||| Knižnice", @@ -305,20 +305,20 @@ }, "actions": { "scan": "Skenovať knižnicu", - "quickScan": "Rýchly sken", - "fullScan": "Úplný sken", "manageUsers": "Spravovať prístup používateľov", - "viewDetails": "Zobraziť detaily" + "viewDetails": "Zobraziť detaily", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken" }, "notifications": { "created": "Knižnica úspešne vytvorená", "updated": "Knižnica úspešne aktualizovaná", "deleted": "Knižnica úspešne odstránená", "scanStarted": "Skenovanie knižnice spustené", + "scanCompleted": "Skenovanie knižnice dokončené", "quickScanStarted": "Rýchly sken spustený", "fullScanStarted": "Úplný sken spustený", - "scanError": "Chyba pri spustení skenu. Skontrolujte logy", - "scanCompleted": "Skenovanie knižnice dokončené" + "scanError": "Chyba pri spustení skenu. Skontrolujte logy" }, "validation": { "nameRequired": "Názov knižnice je povinný", @@ -391,8 +391,6 @@ }, "messages": { "configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.", - "configValidationError": "Overenie konfigurácie zlyhalo:", - "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", "clickPermissions": "Kliknite na oprávnenie pre detaily", "noConfig": "Žiadna konfigurácia nastavená", "allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.", @@ -402,8 +400,10 @@ "allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.", "noLibraries": "Žiadne knižnice nevybrané", "librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.", - "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.", - "requiredHosts": "Požadovaní hostitelia" + "requiredHosts": "Požadovaní hostitelia", + "configValidationError": "Overenie konfigurácie zlyhalo:", + "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", + "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie." }, "placeholders": { "configKey": "kľúč", @@ -446,7 +446,6 @@ "add": "Pridať", "back": "Ísť späť", "bulk_actions": "1 vybraná |||| %{smart_count} vybraných", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Zrušiť", "clear_input_value": "Vymazať hodnotu", "clone": "Klonovať", @@ -470,6 +469,7 @@ "close_menu": "Zavrieť ponuku", "unselect": "Zrušiť výber", "skip": "Preskočiť", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Zdieľať", "download": "Stiahnuť" }, @@ -557,58 +557,52 @@ } }, "message": { - "uploadCover": "Nahrať obrázok obalu", - "removeCover": "Odstrániť obrázok obalu", - "coverUploaded": "Obrázok obalu albumu aktualizovaný", - "coverRemoved": "Obrázok obalu albumu odstránený", - "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", - "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu", "note": "POZNÁMKA", "transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.", "transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.", "songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb", - "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", - "startingInstantMix": "Načítava sa Instant Mix...", - "noTopSongsFound": "Nenašli sa žiadne top skladby", "noPlaylistsAvailable": "Žiadne nie sú dostupné", "delete_user_title": "Odstrániť používateľa '%{name}'", "delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?", - "remove_missing_title": "Odstráňte chýbajúce súbory", - "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", - "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", - "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", "notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača", "notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https", "lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý", "lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť", "lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý", "lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť", - "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", - "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", - "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", "openIn": { "lastfm": "Otvoriť na Last.fm", "musicbrainz": "Otvoriť na MusicBrainz" }, "lastfmLink": "Čítať ďalej...", + "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", + "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", + "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte", "shareOriginalFormat": "Zdieľať v pôvodnom formáte", "shareDialogTitle": "Zdieľať %{resource} '%{name}'", "shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}", - "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", "shareSuccess": "URL skopírovaná do schránky: %{url}", "shareFailure": "Chyba pri kopírovaní URL %{url} do schránky", "downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte" + "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", + "remove_missing_title": "Odstráňte chýbajúce súbory", + "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", + "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", + "noTopSongsFound": "Nenašli sa žiadne top skladby", + "startingInstantMix": "Načítava sa Instant Mix...", + "uploadCover": "Nahrať obrázok obalu", + "removeCover": "Odstrániť obrázok obalu", + "coverUploaded": "Obrázok obalu albumu aktualizovaný", + "coverRemoved": "Obrázok obalu albumu odstránený", + "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", + "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu" }, "menu": { "library": "Knižnica", - "librarySelector": { - "allLibraries": "Všetky knižnice (%{count})", - "multipleLibraries": "%{selected} z %{total} knižníc", - "selectLibraries": "Vyberte knižnice", - "none": "Žiadne" - }, "settings": "Nastavenia", "version": "Verzia", "theme": "Téma", @@ -619,7 +613,6 @@ "language": "Jazyk", "defaultView": "Predvolená stránka", "desktop_notifications": "Oznámenia na ploche", - "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný", "lastfmScrobbling": "Scrobblovať na Last.fm", "listenBrainzScrobbling": "Scrobblovať na ListenBrainz", "replaygain": "Mód ReplayGain", @@ -628,13 +621,20 @@ "none": "Vypnuté", "album": "Použiť Album Gain", "track": "Použiť Track Gain" - } + }, + "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný" } }, "albumList": "Albumy", + "about": "O Navidrome", "playlists": "Zoznamy skladieb", "sharedPlaylists": "Zdieľané zoznamy skladieb", - "about": "O Navidrome" + "librarySelector": { + "allLibraries": "Všetky knižnice (%{count})", + "multipleLibraries": "%{selected} z %{total} knižníc", + "selectLibraries": "Vyberte knižnice", + "none": "Žiadne" + } }, "player": { "playListsText": "Rad", @@ -682,11 +682,11 @@ "currentValue": "Aktuálna hodnota", "configurationFile": "Konfiguračný súbor", "exportToml": "Exportovať konfiguráciu (TOML)", - "downloadToml": "Stiahnuť konfiguráciu (TOML)", "exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML", "exportFailed": "Nepodarilo sa skopírovať konfiguráciu", "devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)", - "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách" + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách", + "downloadToml": "Stiahnuť konfiguráciu (TOML)" } }, "activity": { @@ -694,17 +694,12 @@ "totalScanned": "Naskenované priečinky", "quickScan": "Rýchly sken", "fullScan": "Úplný sken", - "selectiveScan": "Selektívne", "serverUptime": "Doba od spustenia", "serverDown": "OFFLINE", "scanType": "Posledný Sken", "status": "Chyba skenovania", - "elapsedTime": "Uplynutý čas" - }, - "nowPlaying": { - "title": "Práve hrá", - "empty": "Nič sa neprehráva", - "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" + "elapsedTime": "Uplynutý čas", + "selectiveScan": "Selektívne" }, "help": { "title": "Klávesové skratky Navidrome", @@ -714,10 +709,15 @@ "toggle_play": "Prehrať / Pozastaviť", "prev_song": "Predchádzajúca skladba", "next_song": "Nasledujúca skladba", - "current_song": "Prejsť na aktuálnu skladbu", "vol_up": "Zvýšiť hlasitosť", "vol_down": "Znížiť hlasitosť", - "toggle_love": "Pridať túto skladbu do obľúbených" + "toggle_love": "Pridať túto skladbu do obľúbených", + "current_song": "Prejsť na aktuálnu skladbu" } + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" } } \ No newline at end of file diff --git a/resources/i18n/sr.json b/resources/i18n/sr.json index 1cf7e39e7..cf0fc5d2f 100644 --- a/resources/i18n/sr.json +++ b/resources/i18n/sr.json @@ -4,45 +4,54 @@ "song": { "name": "Песма |||| Песме", "fields": { - "album": "Албум", "albumArtist": "Уметник албума", - "artist": "Уметник", - "bitDepth": "Битова", - "bitRate": "Битски проток", - "bpm": "BPM", - "channels": "Канала", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "discSubtitle": "Поднаслов диска", "duration": "Трајање", + "trackNumber": "#", + "playCount": "Пуштано", + "title": "Наслов", + "artist": "Уметник", + "composer": "Композитор", + "album": "Албум", + "path": "Путања фајла", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "size": "Величина фајла", + "updatedAt": "Ажурирано", + "bitRate": "Битски проток", + "bitDepth": "Битска дубина", + "sampleRate": "Учестаност узорковања", + "albumGain": "Појачање албума", + "trackGain": "Појачање нумере", + "channels": "Канали", + "disc": "Диск %{discNumber}", + "discSubtitle": "Поднаслов диска", + "starred": "Омиљено", + "comment": "Коментар", + "rating": "Рејтинг", + "quality": "Квалитет", + "bpm": "BPM", + "playDate": "Последње пуштано", + "createdAt": "Датум додавања", "grouping": "Груписање", - "mappedTags": "Мапиране ознаке", "mood": "Расположење", "participants": "Додатни учесници", - "path": "Путања фајла", - "playCount": "Пуштано", - "playDate": "Последње пуштано", - "quality": "Квалитет", - "rating": "Рејтинг", - "rawTags": "Сирове ознаке", - "size": "Величина фајла", - "starred": "Омиљено", "tags": "Додатне ознаке", - "title": "Наслов", - "trackNumber": "#", - "updatedAt": "Ажурирано", - "year": "Година" + "mappedTags": "Мапиране ознаке", + "rawTags": "Сирове ознаке", + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", - "playNext": "Пусти наредно", "playNow": "Пусти одмах", - "shuffleAll": "Измешај све" + "addToPlaylist": "Додај у плејлисту", + "showInPlaylist": "Прикажи у плејлисти", + "shuffleAll": "Измешај све", + "download": "Преузми", + "playNext": "Пусти наредно", + "info": "Прикажи инфо", + "instantMix": "Инстант микс" } }, "album": { @@ -50,46 +59,48 @@ "fields": { "albumArtist": "Уметник албума", "artist": "Уметник", - "catalogNum": "Каталошки број", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "date": "Датум снимања", "duration": "Трајање", + "songCount": "Песме", + "playCount": "Пуштано", + "size": "Величина", + "name": "Назив", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "date": "Датум снимања", + "originalDate": "Оригинално", + "releaseDate": "Објављено", + "releases": "Издање|||| Издања", + "released": "Објављено", + "updatedAt": "Ажурирано", + "comment": "Коментар", + "rating": "Рејтинг", + "createdAt": "Датум додавања", + "recordLabel": "Издавачка кућа", + "catalogNum": "Каталошки број", + "releaseType": "Тип", "grouping": "Груписање", "media": "Медијум", "mood": "Расположење", - "name": "Назив", - "originalDate": "Оригинално", - "playCount": "Пуштано", - "rating": "Рејтинг", - "recordLabel": "Издавачка кућа", - "releaseDate": "Објављено", - "releaseType": "Тип", - "released": "Објављено", - "releases": "Издање|||| Издања", - "size": "Величина", - "songCount": "Песме", - "updatedAt": "Ажурирано", - "year": "Година" + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", - "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", "playAll": "Пусти", "playNext": "Пусти наредно", + "addToQueue": "Пусти касније", "share": "Дели", - "shuffle": "Измешај" + "shuffle": "Измешај", + "addToPlaylist": "Додај у плејлисту", + "download": "Преузми", + "info": "Прикажи инфо" }, "lists": { "all": "Све", - "mostPlayed": "Најчешће пуштано", "random": "Насумично", "recentlyAdded": "Додато недавно", "recentlyPlayed": "Пуштано недавно", + "mostPlayed": "Најчешће пуштано", "starred": "Омиљено", "topRated": "Најбоље рангирано" } @@ -97,116 +108,136 @@ "artist": { "name": "Уметник |||| Уметници", "fields": { - "albumCount": "Број албума", - "genre": "Жанр", "name": "Назив", + "albumCount": "Број албума", + "songCount": "Број песама", + "size": "Величина", "playCount": "Пуштано", "rating": "Рејтинг", + "genre": "Жанр", "role": "Улога", - "size": "Величина", - "songCount": "Број песама" + "missing": "Недостаје" }, "roles": { "albumartist": "Уметник албума |||| Уметници албума", - "arranger": "Аранжер |||| Аранжери", "artist": "Уметник |||| Уметници", "composer": "Композитор |||| Композитори", "conductor": "Диригент |||| Диригенти", - "director": "Режисер |||| Режисери", - "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", - "engineer": "Инжењер |||| Инжењери", "lyricist": "Текстописац |||| Текстописци", - "mixer": "Миксер |||| Миксери", - "performer": "Извођач |||| Извођачи", + "arranger": "Аранжер |||| Аранжери", "producer": "Продуцент |||| Продуценти", - "remixer": "Ремиксер |||| Ремиксери" + "director": "Режисер |||| Режисери", + "engineer": "Инжењер |||| Инжењери", + "mixer": "Миксер |||| Миксери", + "remixer": "Ремиксер |||| Ремиксери", + "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", + "performer": "Извођач |||| Извођачи", + "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници" + }, + "actions": { + "topSongs": "Најбоље песме", + "shuffle": "Измешај", + "radio": "Радио" } }, "user": { "name": "Корисник |||| Корисници", "fields": { - "changePassword": "Измени лозинку?", - "createdAt": "Креирана", - "currentPassword": "Текућа лозинка", + "userName": "Корисничко име", "isAdmin": "Да ли је Админ", - "lastAccessAt": "Последњи приступ", "lastLoginAt": "Последња пријава", - "name": "Назив", - "newPassword": "Нова лозинка", - "password": "Лозинка", - "token": "Жетон", + "lastAccessAt": "Последњи приступ", "updatedAt": "Ажурирано", - "userName": "Корисничко име" + "name": "Назив", + "password": "Лозинка", + "createdAt": "Креирана", + "changePassword": "Измени лозинку?", + "currentPassword": "Текућа лозинка", + "newPassword": "Нова лозинка", + "token": "Жетон", + "libraries": "Библиотеке" }, "helperTexts": { - "name": "Измене вашег имена ће постати видљиве након следеће пријаве" + "name": "Измене вашег имена ће постати видљиве након следеће пријаве", + "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке" }, "notifications": { "created": "Корисник креиран", - "deleted": "Корисник обрисан", - "updated": "Корисник ажуриран" + "updated": "Корисник ажуриран", + "deleted": "Корисник обрисан" + }, + "validation": { + "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори" }, "message": { + "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.", "clickHereForToken": "Кликните овде да преузмете свој жетон", - "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон." + "selectAllLibraries": "Изабери све библиотеке", + "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама" } }, "player": { "name": "Плејер |||| Плејери", "fields": { - "client": "Клијент", - "lastSeen": "Последњи пут виђен", - "maxBitRate": "Макс. битски проток", "name": "Назив", - "reportRealPath": "Пријављуј реалну путању", - "scrobbleEnabled": "Шаљи скроблове на спољне сервисе", "transcodingId": "Транскодирање", - "userName": "Корисничко име" + "maxBitRate": "Макс. битски проток", + "client": "Клијент", + "userName": "Корисничко име", + "lastSeen": "Последњи пут виђен", + "reportRealPath": "Пријављуј реалну путању", + "scrobbleEnabled": "Шаљи скроблове на спољне сервисе" } }, "transcoding": { "name": "Транскодирање |||| Транскодирања", "fields": { - "command": "Команда", - "defaultBitRate": "Подразумевани битски проток", "name": "Назив", - "targetFormat": "Циљни формат" + "targetFormat": "Циљни формат", + "defaultBitRate": "Подразумевани битски проток", + "command": "Команда" } }, "playlist": { "name": "Плејлиста |||| Плејлисте", "fields": { - "comment": "Коментар", - "createdAt": "Креирана", - "duration": "Трајање", "name": "Назив", + "duration": "Трајање", "ownerName": "Власник", - "path": "Увоз из", "public": "Јавна", + "updatedAt": "Ажурирано", + "createdAt": "Креирана", "songCount": "Песме", + "comment": "Коментар", "sync": "Ауто-увоз", - "updatedAt": "Ажурирано" + "path": "Увоз из" }, "actions": { + "selectPlaylist": "Изабери плејлисту", "addNewPlaylist": "Креирај „%{name}”", "export": "Извези", - "makePrivate": "Учини приватном", + "saveQueue": "Сачувај ред у плејлисту", "makePublic": "Учини јавном", - "selectPlaylist": "Изабери плејлисту" + "makePrivate": "Учини приватном", + "searchOrCreate": "Претражите плејлисте или унесите назив за нову…", + "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту", + "removeFromSelection": "Уклони из избора" }, "message": { "duplicate_song": "Додај дуплиране песме", - "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?" + "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?", + "noPlaylistsFound": "Нема пронађених плејлиста", + "noPlaylists": "Нема доступних плејлиста" } }, "radio": { - "name": "Радио |||| Радији", + "name": "Радио |||| Радио-станице", "fields": { - "createdAt": "Креирана", - "homePageUrl": "URL почетне странице", "name": "Назив", "streamUrl": "URL тока", - "updatedAt": "Ажурирано" + "homePageUrl": "URL почетне странице", + "updatedAt": "Ажурирано", + "createdAt": "Креирана" }, "actions": { "playNow": "Пусти одмах" @@ -215,18 +246,18 @@ "share": { "name": "Дељење |||| Дељења", "fields": { - "contents": "Садржај", - "createdAt": "Креирано", + "username": "Поделио", + "url": "URL", "description": "Опис", "downloadable": "Допушта се преузимање?", + "contents": "Садржај", "expiresAt": "Истиче", - "format": "Формат", "lastVisitedAt": "Последњи пут посећено", + "visitCount": "Број посета", + "format": "Формат", "maxBitRate": "Макс. битски проток", "updatedAt": "Ажурирано", - "url": "URL", - "username": "Поделио", - "visitCount": "Број посета" + "createdAt": "Креирано" }, "notifications": {}, "actions": {} @@ -237,111 +268,246 @@ "fields": { "path": "Путања", "size": "Величина", + "libraryName": "Библиотека", "updatedAt": "Нестао дана" }, "actions": { - "remove": "Уклони" + "remove": "Уклони", + "remove_all": "Уклони све" }, "notifications": { "removed": "Фајл који недостаје, или више њих, је уклоњен" } + }, + "library": { + "name": "Библиотека |||| Библиотеке", + "fields": { + "name": "Назив", + "path": "Путања", + "remotePath": "Удаљена путања", + "lastScanAt": "Последње скенирање", + "songCount": "Песме", + "albumCount": "Албуми", + "artistCount": "Уметници", + "totalSongs": "Песме", + "totalAlbums": "Албуми", + "totalArtists": "Уметници", + "totalFolders": "Фасцикле", + "totalFiles": "Фајлови", + "totalMissingFiles": "Фајлови који недостају", + "totalSize": "Укупна величина", + "totalDuration": "Трајање", + "defaultNewUsers": "Подразумевано за нове кориснике", + "createdAt": "Креирана", + "updatedAt": "Ажурирана" + }, + "sections": { + "basic": "Основне информације", + "statistics": "Статистика" + }, + "actions": { + "scan": "Скенирај библиотеку", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "manageUsers": "Управљај приступом корисника", + "viewDetails": "Прикажи детаље" + }, + "notifications": { + "created": "Библиотека је успешно креирана", + "updated": "Библиотека је успешно ажурирана", + "deleted": "Библиотека је успешно обрисана", + "scanStarted": "Скенирање библиотеке је покренуто", + "quickScanStarted": "Брзо скенирање је покренуто", + "fullScanStarted": "Комплетно скенирање је покренуто", + "scanError": "Грешка при покретању скенирања. Проверите дневнике.", + "scanCompleted": "Скенирање библиотеке је завршено" + }, + "validation": { + "nameRequired": "Назив библиотеке је обавезан", + "pathRequired": "Путања библиотеке је обавезна", + "pathNotDirectory": "Путања библиотеке мора да буде фасцикла", + "pathNotFound": "Путања библиотеке није пронађена", + "pathNotAccessible": "Путања библиотеке није доступна", + "pathInvalid": "Неисправна путања библиотеке" + }, + "messages": { + "deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.", + "scanInProgress": "Скенирање је у току…", + "noLibrariesAssigned": "Овом кориснику нема додељених библиотека" + } + }, + "plugin": { + "name": "Додатак |||| Додаци", + "fields": { + "id": "ИД", + "name": "Назив", + "description": "Опис", + "version": "Верзија", + "author": "Аутор", + "website": "Веб-сајт", + "permissions": "Дозволе", + "enabled": "Омогућено", + "status": "Статус", + "path": "Путања", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Ажурирано", + "createdAt": "Инсталирано", + "configKey": "Кључ", + "configValue": "Вредност", + "allUsers": "Дозволи свим корисницима", + "selectedUsers": "Изабрани корисници", + "allLibraries": "Дозволи све библиотеке", + "selectedLibraries": "Изабране библиотеке", + "allowWriteAccess": "Дозволи приступ за упис" + }, + "sections": { + "status": "Статус", + "info": "Информације о додатку", + "configuration": "Конфигурација", + "manifest": "Манифест", + "usersPermission": "Дозволе корисника", + "libraryPermission": "Дозволе библиотеке" + }, + "status": { + "enabled": "Омогућено", + "disabled": "Онемогућено" + }, + "actions": { + "enable": "Омогући", + "disable": "Онемогући", + "disabledDueToError": "Поправите грешку пре омогућавања", + "disabledUsersRequired": "Изаберите кориснике пре омогућавања", + "disabledLibrariesRequired": "Изаберите библиотеке пре омогућавања", + "addConfig": "Додај конфигурацију", + "rescan": "Поново скенирај" + }, + "notifications": { + "enabled": "Додатак је омогућен", + "disabled": "Додатак је онемогућен", + "updated": "Додатак је ажуриран", + "error": "Грешка при ажурирању додатка" + }, + "validation": { + "invalidJson": "Конфигурација мора да буде исправан JSON" + }, + "messages": { + "configHelp": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.", + "configValidationError": "Провера исправности конфигурације није успела:", + "schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.", + "clickPermissions": "Кликните на дозволу за детаље", + "noConfig": "Конфигурација није постављена", + "allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.", + "noUsers": "Нема изабраних корисника", + "permissionReason": "Разлог", + "usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.", + "allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.", + "noLibraries": "Нема изабраних библиотека", + "librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.", + "allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.", + "requiredHosts": "Потребни хостови" + }, + "placeholders": { + "configKey": "кључ", + "configValue": "вредност" + } } }, "ra": { "auth": { - "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", - "buttonCreateAdmin": "Креирај админа", + "welcome1": "Хвала што сте инсталирали Navidrome!", + "welcome2": "За почетак, креирајте админ корисника", "confirmPassword": "Потврдите лозинку", - "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите", - "logout": "Одјави се", + "buttonCreateAdmin": "Креирај админа", + "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", + "user_menu": "Профил", + "username": "Корисничко име", "password": "Лозинка", "sign_in": "Пријави се", "sign_in_error": "Потврда идентитета није успела, покушајте поново", - "user_menu": "Профил", - "username": "Корисничко име", - "welcome1": "Хвала што сте инсталирали Navidrome!", - "welcome2": "За почетак, креирајте админ корисника" + "logout": "Одјави се", + "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите" }, "validation": { - "email": "Мора да буде исправна и-мејл адреса", "invalidChars": "Молимо вас да користите само слова и цифре", - "maxLength": "Мора да буде %{max} карактера или мање", - "maxValue": "Мора да буде %{max} или мање", - "minLength": "Мора да буде барем %{min} карактера", - "minValue": "Мора да буде барем %{min}", - "number": "Мора да буде број", - "oneOf": "Мора да буде једно од: %{options}", "passwordDoesNotMatch": "Лозинка се не подудара", - "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "required": "Неопходно", + "minLength": "Мора да буде барем %{min} карактера", + "maxLength": "Мора да буде %{max} карактера или мање", + "minValue": "Мора да буде барем %{min}", + "maxValue": "Мора да буде %{max} или мање", + "number": "Мора да буде број", + "email": "Мора да буде исправна и-мејл адреса", + "oneOf": "Мора да буде једно од: %{options}", + "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "unique": "Мора да буде јединствено", "url": "Мора да буде исправна URL адреса" }, "action": { - "add": "Додај", "add_filter": "Додај филтер", + "add": "Додај", "back": "Иди назад", "bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки", "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Откажи", "clear_input_value": "Обриши вредност", "clone": "Клонирај", - "close": "Затвори", - "close_menu": "Затвори мени", "confirm": "Потврди", "create": "Креирај", "delete": "Обриши", - "download": "Преузми", "edit": "Уреди", - "expand": "Развиј", "export": "Извези", "list": "Листа", - "open_menu": "Отвори мени", "refresh": "Освежи", - "remove": "Уклони", "remove_filter": "Уклони овај филтер", + "remove": "Уклони", "save": "Сачувај", "search": "Тражи", - "share": "Дели", "show": "Прикажи", - "skip": "Прескочи", "sort": "Сортирај", "undo": "Поништи", - "unselect": "Уклони избор" + "expand": "Развиј", + "close": "Затвори", + "open_menu": "Отвори мени", + "close_menu": "Затвори мени", + "unselect": "Уклони избор", + "skip": "Прескочи", + "share": "Дели", + "download": "Преузми" }, "boolean": { - "false": "Не", - "true": "Да" + "true": "Да", + "false": "Не" }, "page": { "create": "Креирај %{name}", "dashboard": "Контролна табла", "edit": "%{name} #%{id}", - "empty": "Још увек нема %{name}.", "error": "Нешто је пошло наопако", - "invite": "Желите ли да се дода?", "list": "%{name}", "loading": "Учитава се", "not_found": "Није пронађено", - "show": "%{name} #%{id}" + "show": "%{name} #%{id}", + "empty": "Још увек нема %{name}.", + "invite": "Желите ли да се дода?" }, "input": { "file": { - "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете." + "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете." }, "image": { - "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете." - }, - "password": { - "toggle_hidden": "Прикажи лозинку", - "toggle_visible": "Сакриј лозинку" + "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете." }, "references": { "all_missing": "Не могу да се нађу подаци референци.", "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", "single_missing": "Изгледа да придружена референца више није доступна." + }, + "password": { + "toggle_visible": "Сакриј лозинку", + "toggle_hidden": "Прикажи лозинку" } }, "message": { @@ -357,161 +523,203 @@ "loading": "Страница се учитава, сачекајте мало", "no": "Не", "not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.", - "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?", - "yes": "Да" + "yes": "Да", + "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?" }, "navigation": { - "next": "Наредна", - "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "no_results": "Није пронађен ниједан резултат", - "page_out_from_begin": "Не може да се иде испред странице 1", - "page_out_from_end": "Не може да се иде након последње странице", + "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "page_out_of_boundaries": "Број странице %{page} је ван опсега", + "page_out_from_end": "Не може да се иде након последње странице", + "page_out_from_begin": "Не може да се иде испред странице 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", "page_rows_per_page": "Ставки по страници:", - "prev": "Претход", + "next": "Наредна", + "prev": "Претх.", "skip_nav": "Прескочи на садржај" }, "notification": { - "bad_item": "Неисправни елемент", - "canceled": "Акција је отказана", + "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано", "created": "Елемент је креиран", - "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", - "http_error": "Грешка у комуникацији са сервером", - "i18n_error": "Не могу да се учитају преводи за наведени језик", + "bad_item": "Неисправни елемент", "item_doesnt_exist": "Елемент не постоји", + "http_error": "Грешка у комуникацији са сервером", + "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", + "i18n_error": "Не могу да се учитају преводи за наведени језик", + "canceled": "Акција је отказана", "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", - "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.", - "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано" + "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор." }, "toggleFieldsMenu": { "columnsToDisplay": "Колоне за приказ", - "grid": "Мрежа", "layout": "Распоред", + "grid": "Мрежа", "table": "Табела" } }, "message": { - "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", - "delete_user_title": "Брисање корисника ’%{name}’", - "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", - "downloadOriginalFormat": "Преузми у оригиналном формату", - "lastfmLink": "Прочитај још...", - "lastfmLinkFailure": "Last.fm није могао да се повеже", - "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", - "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", - "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", - "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", - "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", - "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", - "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", - "noPlaylistsAvailable": "Није доступна ниједна", + "uploadCover": "Отпреми омот", + "removeCover": "Уклони омот", + "coverUploaded": "Омот је ажуриран", + "coverRemoved": "Омот је уклоњен", + "coverUploadError": "Грешка при отпремању омота", + "coverRemoveError": "Грешка при уклањању омота", "note": "НАПОМЕНА", + "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", + "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.", + "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", + "noSimilarSongsFound": "Нису пронађене сличне песме", + "startingInstantMix": "Учитава се инстант микс…", + "noTopSongsFound": "Нису пронађене најбоље песме", + "noPlaylistsAvailable": "Није доступна ниједна", + "delete_user_title": "Брисање корисника ’%{name}’", + "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", + "remove_missing_title": "Уклони фајлове који недостају", + "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "remove_all_missing_title": "Уклони све фајлове који недостају", + "remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", "notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења", "notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола", + "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", + "lastfmLinkFailure": "Last.fm није могао да се повеже", + "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", + "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", + "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", + "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", "openIn": { "lastfm": "Отвори у Last.fm", "musicbrainz": "Отвори у MusicBrainz" }, - "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", - "remove_missing_title": "Уклони фајлове који недостају", + "lastfmLink": "Прочитај још...", + "shareOriginalFormat": "Подели у оригиналном формату", + "shareDialogTitle": "Подели %{resource} ’%{name}’", "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", - "shareDialogTitle": "Подели %{resource} ’%{name}’", - "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", - "shareOriginalFormat": "Подели у оригиналном формату", "shareSuccess": "URL је копиран у клипборд: %{url}", - "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", - "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", - "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања." + "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", + "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", + "downloadOriginalFormat": "Преузми у оригиналном формату" }, "menu": { - "about": "О", - "albumList": "Албуми", "library": "Библиотека", + "librarySelector": { + "allLibraries": "Све библиотеке (%{count})", + "multipleLibraries": "%{selected} од %{total} библиотека", + "selectLibraries": "Изабери библиотеке", + "none": "Ниједна" + }, + "settings": "Подешавања", + "version": "Верзија", + "theme": "Тема", "personal": { "name": "Лична", "options": { + "theme": "Тема", + "language": "Језик", "defaultView": "Подразумевани поглед", "desktop_notifications": "Десктоп обавештења", - "gain": { - "album": "Користи Album појачање", - "none": "Искључено", - "track": "Користи Track појачање" - }, - "language": "Језик", "lastfmNotConfigured": "Није подешен Last.fm API-кључ", "lastfmScrobbling": "Скроблуј на Last.fm", "listenBrainzScrobbling": "Скроблуј на ListenBrainz", - "preAmp": "ReplayGain претпојачање (dB)", "replaygain": "ReplayGain режим", - "theme": "Тема" + "preAmp": "ReplayGain претпојачање (dB)", + "gain": { + "none": "Искључено", + "album": "Користи Album појачање", + "track": "Користи Track појачање" + } } }, + "albumList": "Албуми", "playlists": "Плејлисте", - "settings": "Подешавања", "sharedPlaylists": "Дељене плејлисте", - "theme": "Тема", - "version": "Верзија" + "about": "О" }, "player": { - "clickToDeleteText": "Кликните да обришете %{name}", - "clickToPauseText": "Кликни за паузирање", - "clickToPlayText": "Кликни за пуштање", + "playListsText": "Ред за пуштање", + "openText": "Отвори", "closeText": "Затвори", + "notContentText": "Нема музике", + "clickToPlayText": "Кликните за пуштање", + "clickToPauseText": "Кликните за паузирање", + "nextTrackText": "Наредна нумера", + "previousTrackText": "Претходна нумера", + "reloadText": "Поново учитај", + "volumeText": "Јачина", + "toggleLyricText": "Укљ./Искљ. стихове", + "toggleMiniModeText": "Умањи", "destroyText": "Уништи", "downloadText": "Преузми", + "removeAudioListsText": "Обриши аудио листе", + "clickToDeleteText": "Кликните да обришете %{name}", "emptyLyricText": "Нема стихова", - "nextTrackText": "Наредна нумера", - "notContentText": "Нема музике", - "openText": "Отвори", - "playListsText": "Ред за пуштање", "playModeText": { "order": "По редоследу", "orderLoop": "Понови", - "shufflePlay": "Измешај", - "singleLoop": "Понови једну" - }, - "previousTrackText": "Претходна нумера", - "reloadText": "Поново учитај", - "removeAudioListsText": "Обриши аудио листе", - "toggleLyricText": "Укљ./Искљ. стихове", - "toggleMiniModeText": "Умањи", - "volumeText": "Јачина" + "singleLoop": "Понови једну", + "shufflePlay": "Измешај" + } }, "about": { "links": { - "featureRequests": "Захтеви за функцијама", "homepage": "Почетна страница", + "source": "Изворни кôд", + "featureRequests": "Захтеви за функције", + "lastInsightsCollection": "Последња колекција увида", "insights": { "disabled": "Искључено", "waiting": "Чека се" - }, - "lastInsightsCollection": "Последња колекција увида", - "source": "Изворни кôд" + } + }, + "tabs": { + "about": "О програму", + "config": "Конфигурација" + }, + "config": { + "configName": "Назив конфигурације", + "environmentVariable": "Променљива окружења", + "currentValue": "Тренутна вредност", + "configurationFile": "Конфигурациони фајл", + "exportToml": "Извези конфигурацију (TOML)", + "downloadToml": "Преузми конфигурацију (TOML)", + "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату", + "exportFailed": "Копирање конфигурације није успело", + "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)", + "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама" } }, "activity": { - "fullScan": "Комплетно скенирање", - "quickScan": "Брзо скенирање", - "serverDown": "ВАН МРЕЖЕ", - "serverUptime": "Сервер се извршава", "title": "Активност", - "totalScanned": "Укупан број скенираних фолдера" + "totalScanned": "Укупан број скенираних фолдера", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "selectiveScan": "Селективно", + "serverUptime": "Сервер се извршава", + "serverDown": "ВАН МРЕЖЕ", + "scanType": "Последње скенирање", + "status": "Грешка скенирања", + "elapsedTime": "Протекло време" + }, + "nowPlaying": { + "title": "Сада се пушта", + "empty": "Ништа се не пушта", + "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута" }, "help": { "title": "Navidrome пречице", "hotkeys": { - "current_song": "Иди на текућу песму", - "next_song": "Наредна песма", - "prev_song": "Претходна песма", "show_help": "Прикажи ову помоћ", - "toggle_love": "Додај ову нумеру у омиљене", "toggle_menu": "Укљ./Искљ. бочну траку менија", "toggle_play": "Пусти / Паузирај", + "prev_song": "Претходна песма", + "next_song": "Наредна песма", + "current_song": "Иди на текућу песму", + "vol_up": "Појачај", "vol_down": "Утишај", - "vol_up": "Појачај" + "toggle_love": "Додај ову нумеру у омиљене" } } } diff --git a/resources/i18n/th.json b/resources/i18n/th.json index b445d7464..fde89494e 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -38,7 +38,9 @@ "missing": "หายไป", "libraryName": "ห้องสมุด", "composer": "ผู้แต่ง", - "disc": "" + "disc": "พื้นที่ %{discNumber}", + "albumGain": "เนื้อหาในอัลบั้ม", + "trackGain": "เนื้อหาในเพลง" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -355,7 +357,7 @@ "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", - "allowWriteAccess": "" + "allowWriteAccess": "อนุญาตให้เขียน" }, "sections": { "status": "สถานะ", @@ -401,7 +403,7 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น" }, "placeholders": { "configKey": "คีย์", @@ -591,7 +593,13 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...", + "uploadCover": "อัพโหลดภาพหน้าปก", + "removeCover": "ลบถาพหน้าปก", + "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", + "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", + "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", + "coverRemoveError": "ลบภาพหน้าปกผิดพลาด" }, "menu": { "library": "ห้องสมุดเพลง", @@ -712,4 +720,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} +} \ No newline at end of file diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index d1fdb2ed4..ff387aff8 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -36,7 +36,11 @@ "bitDepth": "Bit derinliği", "sampleRate": "Örnekleme Oranı", "missing": "Eksik", - "libraryName": "Kütüphane" + "libraryName": "Kütüphane", + "composer": "Besteci", + "disc": "Disk %{discNumber}", + "albumGain": "Albüm Kazancı", + "trackGain": "Parça Kazancı" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -46,7 +50,8 @@ "download": "İndir", "playNext": "Dinlenenden Sonra Oynat", "info": "Bilgiler", - "showInPlaylist": "Çalma Listesinde Göster" + "showInPlaylist": "Çalma Listesinde Göster", + "instantMix": "Anında Karışım" } }, "album": { @@ -328,6 +333,82 @@ "scanInProgress": "Tarama devam ediyor...", "noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı" } + }, + "plugin": { + "name": "Eklenti |||| Eklentiler", + "fields": { + "id": "Kimlik", + "name": "Ad", + "description": "Açıklama", + "version": "Sürüm", + "author": "Geliştirici", + "website": "Web Sitesi", + "permissions": "İzinler", + "enabled": "Etkin", + "status": "Durum", + "path": "Yol", + "lastError": "Son Hata", + "hasError": "Hata", + "updatedAt": "Güncellendi", + "createdAt": "Yüklendi", + "configKey": "Anahtar", + "configValue": "Değer", + "allUsers": "Tüm Kullanıcılara İzin Ver", + "selectedUsers": "Seçili Kullanıcılar", + "allLibraries": "Tüm Kütüphanelere İzin Ver", + "selectedLibraries": "Seçili Kütüphaneler", + "allowWriteAccess": "Yazma Erişimine İzin Ver" + }, + "sections": { + "status": "Durum", + "info": "Eklenti Bilgileri", + "configuration": "Yapılandırma", + "manifest": "Manifest", + "usersPermission": "Kullanıcı İzinleri", + "libraryPermission": "Kütüphane İzinleri" + }, + "status": { + "enabled": "Etkin", + "disabled": "Devre Dışı" + }, + "actions": { + "enable": "Etkinleştir", + "disable": "Devre Dışı Bırak", + "disabledDueToError": "Etkinleştirmeden Önce Hatayı Düzeltin", + "disabledUsersRequired": "Etkinleştirmeden Önce Kullanıcı Seçin", + "disabledLibrariesRequired": "Etkinleştirmeden Önce Kütüphane Seçin", + "addConfig": "Yapılandırma Ekle", + "rescan": "Yeniden Tara" + }, + "notifications": { + "enabled": "Eklenti etkinleştirildi", + "disabled": "Eklenti devre dışı bırakıldı", + "updated": "Eklenti güncellendi", + "error": "Eklenti güncellenirken hata oluştu" + }, + "validation": { + "invalidJson": "Yapılandırma geçerli bir JSON olmalı" + }, + "messages": { + "configHelp": "Eklentiyi anahtar-değer çiftleriyle yapılandırın. Eklenti yapılandırma gerektirmiyorsa boş bırakın.", + "clickPermissions": "Ayrıntıları görmek için bir izne tıklayın", + "noConfig": "Yapılandırma ayarlanmamış", + "allUsersHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kullanıcılara erişebilir.", + "noUsers": "Kullanıcı seçilmedi", + "permissionReason": "Gerekçe", + "usersRequired": "Bu eklenti kullanıcı bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kullanıcıları seçin veya 'Tüm Kullanıcılara İzin Ver' seçeneğini etkinleştirin.", + "allLibrariesHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kütüphanelere erişebilir.", + "noLibraries": "Kütüphane seçilmedi", + "librariesRequired": "Bu eklenti kütüphane bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kütüphaneleri seçin veya 'Tüm Kütüphanelere İzin Ver' \nseçeneğini etkinleştirin.", + "requiredHosts": "Gerekli Sunucular", + "configValidationError": "Yapılandırma doğrulanamadı:", + "schemaRenderError": "Yapılandırma formu oluşturulamadı. Eklentinin şeması geçersiz olabilir.", + "allowWriteAccessHelp": "Etkinleştirildiğinde eklenti, kütüphane dizinlerindeki dosyaları değiştirebilir. Eklentiler varsayılan olarak salt okunur erişime sahiptir." + }, + "placeholders": { + "configKey": "anahtar", + "configValue": "değer" + } } }, "ra": { @@ -511,7 +592,14 @@ "remove_all_missing_title": "Tüm eksik dosyaları kaldırın", "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.", "noSimilarSongsFound": "Benzer şarkı bulunamadı", - "noTopSongsFound": "En iyi şarkı listesi boş" + "noTopSongsFound": "En iyi şarkı listesi boş", + "startingInstantMix": "Anında Karışım yükleniyor...", + "uploadCover": "Kapak Görseli Yükle", + "removeCover": "Kapak Görselini Kaldır", + "coverUploaded": "Kapak görseli güncellendi", + "coverRemoved": "Kapak görseli kaldırıldı", + "coverUploadError": "Kapak görseli yüklenirken hata oluştu", + "coverRemoveError": "Kapak görseli kaldırılırken hata oluştu" }, "menu": { "library": "Kütüphane", @@ -597,7 +685,8 @@ "exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı", "exportFailed": "Yapılandırma kopyalanamadı", "devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)", - "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir" + "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir", + "downloadToml": "Yapılandırmayı İndir (TOML)" } }, "activity": { diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 63ea5cf60..21778506a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -6,7 +6,7 @@ "fields": { "albumArtist": "专辑艺人", "duration": "时长", - "trackNumber": "音轨号", + "trackNumber": "曲目序号", "playCount": "播放次数", "title": "标题", "artist": "艺人", @@ -22,6 +22,8 @@ "bitRate": "比特率", "bitDepth": "位深度", "sampleRate": "采样率", + "albumGain": "专辑增益", + "trackGain": "曲目增益", "channels": "声道", "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", @@ -142,7 +144,7 @@ "name": "用户", "fields": { "userName": "用户名", - "isAdmin": "是否管理员", + "isAdmin": "是否为管理员", "lastLoginAt": "上次登录", "lastAccessAt": "上次访问", "updatedAt": "更新于", @@ -623,11 +625,11 @@ "lastfmScrobbling": "启用 Last.fm 的个性化记录", "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", "replaygain": "回放增益", - "preAmp": "前置放大器 (dB)", + "preAmp": "回放增益 - 前置放大 (dB)", "gain": { - "none": "禁用增益", - "album": "使用专辑增益信息", - "track": "使用歌曲增益信息" + "none": "禁用", + "album": "使用专辑增益", + "track": "使用曲目增益" } } }, diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 92b4af3d0..d00ae2ac3 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,9 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "光碟 %{discNumber}" + "disc": "光碟 %{discNumber}", + "albumGain": "專輯增益", + "trackGain": "曲目增益" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +720,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 16dddd504..294654b6a 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -110,7 +110,9 @@ main: lyrics: # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` aliases: [ uslt:description, lyrics, unsyncedlyrics ] - maxLength: 32768 + # Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song, + # while still bounding against pathological tags. + maxLength: 1048576 type: pair # ex: lyrics:eng, lyrics:xxx comment: aliases: [ comm:description, comment, ©cmt, description, icmt ] diff --git a/scanner/controller.go b/scanner/controller.go index 94248ffd0..463718ba3 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "sync" "sync/atomic" "time" @@ -13,11 +15,11 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/pl" "golang.org/x/time/rate" ) @@ -38,7 +40,7 @@ func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, br devExternalScanner: conf.Server.DevExternalScanner, } if !c.devExternalScanner { - c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) + c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) } return c } @@ -212,6 +214,16 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ ctx := request.AddValues(s.rootCtx, requestCtx) ctx = auth.WithAdminUser(ctx, s.ds) + // A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens + // inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must + // be read before the scan: ScanEnd clears the flag. + effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets) + if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Scanner: Error marking DB analysis pending", err) + } + } + // Send the initial scan status event s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) progress := make(chan *ProgressInfo, 100) @@ -230,6 +242,15 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ if scanError != nil { _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) } + // Refresh the query-planner statistics after a successful full scan. This must run in the + // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the + // server's pooled connections; their shared schema cache keeps the old statistics until the + // process restarts. + if effectiveFullScan && scanError == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Scanner: Error analyzing DB", err) + } + } // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") @@ -256,18 +277,73 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ // This is a global variable that is used to prevent multiple scans from running at the same time. // "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg -var running atomic.Bool +var ( + running atomic.Bool + scanMaintenanceMux sync.Mutex +) func lockScan(ctx context.Context) (func(), error) { if !running.CompareAndSwap(false, true) { log.Debug(ctx, "Scanner already running, ignoring request") return func() {}, ErrAlreadyScanning } + scanMaintenanceMux.Lock() return func() { + scanMaintenanceMux.Unlock() running.Store(false) }, nil } +// LockForMaintenance prevents a scan from starting while database maintenance is running. +func LockForMaintenance() (func(), bool) { + if !scanMaintenanceMux.TryLock() { + return func() {}, false + } + if running.Load() { + scanMaintenanceMux.Unlock() + return func() {}, false + } + return scanMaintenanceMux.Unlock, true +} + +// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted +// full scan in one of the included libraries. +func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { + if fullScan { + return true + } + return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { + return library.FullScanInProgress + }) +} + +func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool { + return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool { + return library.LastScanAt.IsZero() + }) +} + +// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is +// empty) matches pred. +func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { + libraries, err := ds.Library(ctx).GetAll() + if err != nil { + return false + } + if len(targets) == 0 { + return slices.ContainsFunc(libraries, pred) + } + + targeted := make(map[int]struct{}, len(targets)) + for _, target := range targets { + targeted[target.LibraryID] = struct{}{} + } + return slices.ContainsFunc(libraries, func(library model.Library) bool { + _, ok := targeted[library.ID] + return ok && pred(library) + }) +} + func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) { s.count.Store(0) s.folderCount.Store(0) diff --git a/scanner/controller_test.go b/scanner/controller_test.go index d60d432b4..e4814da64 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -55,3 +55,41 @@ var _ = Describe("Controller", func() { }) }) }) + +var _ = Describe("LockForMaintenance", func() { + It("allows only one database maintenance operation at a time", func() { + release, ok := scanner.LockForMaintenance() + Expect(ok).To(BeTrue()) + DeferCleanup(release) + + _, ok = scanner.LockForMaintenance() + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("EffectiveFullScan", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + libraries := &tests.MockLibraryRepo{} + libraries.SetData(model.Libraries{ + {ID: 1, FullScanInProgress: true}, + {ID: 2}, + }) + ds = &tests.MockDataStore{MockedLibrary: libraries} + }) + + It("detects an interrupted full scan in a targeted library", func() { + targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) + }) + + It("detects an interrupted full scan when scanning all libraries", func() { + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue()) + }) + + It("ignores interrupted full scans in untargeted libraries", func() { + targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) + }) +}) diff --git a/scanner/external.go b/scanner/external.go index 29ca90be6..7f573fe3e 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -45,8 +45,8 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod "scan", "--nobanner", "--subprocess", "--configfile", conf.Server.ConfigFile, - "--datafolder", conf.Server.DataFolder, - "--cachefolder", conf.Server.CacheFolder, + "--datafolder", conf.Server.DataFolder.String(), + "--cachefolder", conf.Server.CacheFolder.String(), } // Add targets if provided @@ -97,8 +97,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) { if err := cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr)) } else { _ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err)) diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 3ccbd8961..2906a2c09 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -1,6 +1,7 @@ package metadata_old import ( + "context" "encoding/json" "fmt" "math" @@ -205,13 +206,14 @@ func (t Tags) Lyrics() string { basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") for _, value := range basicLyrics { - lyrics, err := model.ToLyrics("xxx", value) + parsed, err := model.ParseLyrics(context.Background(), ".lrc", "xxx", []byte(value)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } for tag, value := range t.Tags { @@ -223,13 +225,14 @@ func (t Tags) Lyrics() string { } for _, text := range value { - lyrics, err := model.ToLyrics(language, text) + parsed, err := model.ParseLyrics(context.Background(), ".lrc", language, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } } } diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go index 2d21e07eb..aff1ede9c 100644 --- a/scanner/metadata_old/metadata_internal_test.go +++ b/scanner/metadata_old/metadata_internal_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tags", func() { var t *Tags BeforeEach(func() { t = &Tags{Tags: map[string][]string{ - "fbpm": []string{"141.7"}, + "fbpm": {"141.7"}, }} }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 38967832c..5e898590b 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -360,7 +360,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later for i := range entry.artists { err = artistRepo.Put(&entry.artists[i], "name", - "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "updated_at") + "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") if err != nil { log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) return err diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index d52743966..8ba014235 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "errors" "fmt" "os" "strings" @@ -10,6 +11,7 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -18,12 +20,13 @@ import ( ) type phasePlaylists struct { - ctx context.Context - scanState *scanState - ds model.DataStore - pls playlists.Playlists - cw artwork.CacheWarmer - refreshed atomic.Uint32 + ctx context.Context + scanState *scanState + ds model.DataStore + pls playlists.Playlists + cw artwork.CacheWarmer + refreshed atomic.Uint32 + pendingImport bool } func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { @@ -49,22 +52,41 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { log.Info(p.ctx, "Playlists will not be imported, AutoImportPlaylists is set to false") return nil } - u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin || u.ID == "" { - log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ - "Please create an admin user first, and then update the playlists for them to be imported") - return nil + + // Resolve the admin at phase time (the producer runs late in the scan), so an + // admin created while the scan was in progress is picked up. Assigned once, + // before any put() below, so the channel send synchronizes it with the stages. + admin, err := p.ds.User(p.ctx).FindFirstAdmin() + if err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("finding admin user: %w", err) + } + noAdmin := admin == nil || admin.ID == "" + if noAdmin { + return p.deferImport() + } + p.ctx = request.WithUser(p.ctx, *admin) + + // When recovering a deferred import, scan all playlist folders, not just touched ones. + pending, err := p.importPending() + if err != nil { + return fmt.Errorf("checking pending playlist import: %w", err) + } + p.pendingImport = pending + var cursor model.FolderCursor + if p.pendingImport { + cursor, err = p.ds.Folder(p.ctx).GetAllWithPlaylists() + } else { + cursor, err = p.ds.Folder(p.ctx).GetTouchedWithPlaylists() + } + if err != nil { + return fmt.Errorf("loading folders with playlists: %w", err) } count := 0 - cursor, err := p.ds.Folder(p.ctx).GetTouchedWithPlaylists() - if err != nil { - return fmt.Errorf("loading touched folders: %w", err) - } - log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh") + log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh", "pendingImport", p.pendingImport) for folder, err := range cursor { if err != nil { - return fmt.Errorf("loading touched folder: %w", err) + return fmt.Errorf("loading folder with playlists: %w", err) } count++ put(&folder) @@ -78,6 +100,23 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } +// deferImport records the pending-import flag so a later scan with an admin can +// import the playlists, and returns an error if the flag can't be persisted (so +// the scan does not complete as successful without recording the recovery). +func (p *phasePlaylists) deferImport() error { + if err := p.ds.Property(p.ctx).Put(consts.PlaylistsImportPendingFlagKey, "1"); err != nil { + return fmt.Errorf("recording pending playlist import: %w", err) + } + log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+ + "They will be imported automatically once an admin user is created.") + return nil +} + +func (p *phasePlaylists) importPending() (bool, error) { + v, err := p.ds.Property(p.ctx).DefaultGet(consts.PlaylistsImportPendingFlagKey, "0") + return v == "1", err +} + func (p *phasePlaylists) stages() []ppl.Stage[*model.Folder] { return []ppl.Stage[*model.Folder]{ ppl.NewStage(p.processPlaylistsInFolder, ppl.Name("process playlists in folder"), ppl.Concurrency(3)), @@ -123,6 +162,11 @@ func (p *phasePlaylists) finalize(err error) error { } else { p.scanState.changesDetected.Store(true) } + if p.pendingImport && err == nil { + if derr := p.ds.Property(p.ctx).Delete(consts.PlaylistsImportPendingFlagKey); derr != nil { + log.Warn(p.ctx, "Scanner: Could not clear pending playlist-import flag", derr) + } + } logF(p.ctx, "Scanner: Finished refreshing playlists", "refreshed", refreshed, err) return err } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 0e01a7549..49ffc7fb7 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -9,10 +9,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -30,14 +30,22 @@ var _ = Describe("phasePlaylists", func() { cw artwork.CacheWarmer ) + var userRepo *tests.MockedUserRepo + var propRepo *tests.MockedPropertyRepo + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AutoImportPlaylists = true ctx = context.Background() - ctx = request.WithUser(ctx, model.User{ID: "123", IsAdmin: true}) folderRepo = &mockFolderRepository{} + userRepo = tests.CreateMockUserRepo() + // An admin user exists by default, so playlist import proceeds. + Expect(userRepo.Put(&model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) + propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{ - MockedFolder: folderRepo, + MockedFolder: folderRepo, + MockedUser: userRepo, + MockedProperty: propRepo, } pls = &mockPlaylists{} cw = artwork.NoopCacheWarmer() @@ -84,6 +92,81 @@ var _ = Describe("phasePlaylists", func() { Expect(called).To(BeFalse()) Expect(err).To(MatchError(ContainSubstring("error loading folders"))) }) + + It("sets the pending flag and imports nothing when no admin user exists", func() { + // Remove the admin user; produce resolves the admin at phase time. + userRepo.Data = map[string]*model.User{} + folderRepo.SetData(map[*model.Folder]error{ + {Path: "/path/to/folder1"}: nil, + }) + + called := false + err := phase.produce(func(folder *model.Folder) { called = true }) + + Expect(err).ToNot(HaveOccurred()) + Expect(called).To(BeFalse()) + v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(v).To(Equal("1")) + }) + + It("returns an error (not a silent defer) on a datastore failure resolving the admin", func() { + userRepo.Error = errors.New("db is locked") + + err := phase.produce(func(folder *model.Folder) {}) + + Expect(err).To(MatchError(ContainSubstring("finding admin user"))) + // Must NOT have set the pending flag on a real error. + _, getErr := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(getErr).To(HaveOccurred()) + }) + + It("returns an error when the pending flag cannot be persisted", func() { + userRepo.Data = map[string]*model.User{} // no admin -> defer path + propRepo.Error = errors.New("property table unavailable") + + err := phase.produce(func(folder *model.Folder) {}) + + Expect(err).To(MatchError(ContainSubstring("recording pending playlist import"))) + }) + + It("imports all playlist folders when the pending flag is set", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + folderRepo.SetAllData(map[*model.Folder]error{ + {Path: "/path/to/folder1"}: nil, + {Path: "/path/to/folder2"}: nil, + }) + // Touched set is empty: proves selection used GetAllWithPlaylists. + folderRepo.SetData(map[*model.Folder]error{}) + + var produced []*model.Folder + err := phase.produce(func(folder *model.Folder) { produced = append(produced, folder) }) + + Expect(err).ToNot(HaveOccurred()) + Expect(produced).To(HaveLen(2)) + Expect(phase.pendingImport).To(BeTrue()) + }) + }) + + Describe("finalize", func() { + It("clears the pending flag after a successful pending import", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + phase.pendingImport = true + + Expect(phase.finalize(nil)).To(Succeed()) + + _, err := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(err).To(HaveOccurred()) // deleted + }) + + It("keeps the pending flag when the import failed", func() { + Expect(propRepo.Put(consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) + phase.pendingImport = true + + Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred()) + + v, _ := propRepo.Get(consts.PlaylistsImportPendingFlagKey) + Expect(v).To(Equal("1")) + }) }) Describe("processPlaylistsInFolder", func() { @@ -141,12 +224,13 @@ func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Fold type mockFolderRepository struct { model.FolderRepository - data map[*model.Folder]error + data map[*model.Folder]error + allData map[*model.Folder]error } -func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { +func cursorFromData(data map[*model.Folder]error) model.FolderCursor { return func(yield func(model.Folder, error) bool) { - for folder, err := range f.data { + for folder, err := range data { if err != nil { if !yield(model.Folder{}, err) { return @@ -157,9 +241,21 @@ func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, er return } } - }, nil + } +} + +func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { + return cursorFromData(f.data), nil +} + +func (f *mockFolderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { + return cursorFromData(f.allData), nil } func (f *mockFolderRepository) SetData(m map[*model.Folder]error) { f.data = m } + +func (f *mockFolderRepository) SetAllData(m map[*model.Folder]error) { + f.allData = m +} diff --git a/scanner/scanner.go b/scanner/scanner.go index 871b0c696..27e2b19d2 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -161,9 +160,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Update last_scan_completed_at for all libraries s.runUpdateLibraries(ctx, &state), - - // Optimize DB - s.runOptimize(ctx), ) if err != nil { log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) @@ -280,15 +276,6 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun } } -func (s *scannerImpl) runOptimize(ctx context.Context) func() error { - return func() error { - start := time.Now() - db.Optimize(ctx) - log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) - return nil - } -} - func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6c70eb268..17772bf9d 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -4,10 +4,12 @@ import ( "context" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" @@ -80,7 +82,7 @@ var _ = Describe("ScanFolders", Ordered, func() { rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) - createFS(fstest.MapFS{ + fsys = createFS(fstest.MapFS{ "rock/track1.mp3": rock(track(1, "Rock Track 1")), "rock/track2.mp3": rock(track(2, "Rock Track 2")), "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), @@ -122,6 +124,38 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + }) + + Describe("Planner statistics maintenance", func() { + It("does not mark routine quick-scan changes for immediate analysis", func() { + rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) + fsys = createFS(fstest.MapFS{ + "rock/track1.mp3": rock(track(1, "Rock Track 1")), + }) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + + fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("does not treat an interrupted scan in an untargeted library as a full scan", func() { + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed()) + + lastAnalyze := "2026-07-09T12:00:00Z" + Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 9ee6fc89b..10be0401f 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -2,17 +2,34 @@ package scanner_test import ( "context" + "io/fs" "os" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" ) +// The local storage is registered in this test binary, so any spec (or background watcher) +// touching a file:// library needs a default extractor to avoid a startup fatal. +type noopSuiteExtractor struct{} + +func (noopSuiteExtractor) Parse(...string) (map[string]metadata.Info, error) { return nil, nil } +func (noopSuiteExtractor) Version() string { return "0" } + +func init() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return noopSuiteExtractor{} + }) +} + func TestScanner(t *testing.T) { // Only run goleak checks when the GOLEAK env var is set if os.Getenv("GOLEAK") != "" { diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 7bf91d64f..cc3720717 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -2,9 +2,11 @@ package scanner_test import ( "context" + "database/sql" "errors" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/google/uuid" @@ -188,6 +190,43 @@ var _ = Describe("Scanner", Ordered, func() { }) }) + Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() { + BeforeEach(func() { + goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018}) + createFS(fstest.MapFS{ + "GØGGS/Pre Strike Sweep/01 - Falling For You.mp3": goggs(track(1, "Falling For You")), + }) + }) + + searchNormalized := func() string { + var sn string + Expect(db.Db().QueryRowContext(ctx, + "SELECT search_normalized FROM artist WHERE name = 'GØGGS'").Scan(&sn)).To(Succeed()) + return sn + } + + It("repopulates a stale search_normalized on a full rescan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(searchNormalized()).To(Equal("GOGGS")) + + // Simulate the stale value left by the FTS5 migration's SQL back-fill + _, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'") + Expect(err).ToNot(HaveOccurred()) + + // Backdate the folder so the next full scan reliably sees it as outdated. + // isOutdated() compares folder.updated_at (written by this scan) against the + // next scan's last_scan_started_at with a strict Before(); back-to-back scans + // can capture both within one clock tick on Windows (coarse wall-clock), making + // the refresh flaky. Backdating forces the comparison to be unambiguous. + _, err = db.Db().ExecContext(ctx, + "UPDATE folder SET updated_at = ?", time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + + Expect(runScanner(ctx, true)).To(Succeed()) + Expect(searchNormalized()).To(Equal("GOGGS")) + }) + }) + Context("Ignored entries", func() { BeforeEach(func() { revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) @@ -531,6 +570,69 @@ var _ = Describe("Scanner", Ordered, func() { })).To(Equal(int64(2))) }) + It("leaves no non-missing orphan artist after purging an artist's only content", func() { + // Guards the orphan case: with PurgeMissing on, removing an artist's last file hard-deletes + // its media_file_artists rows, RefreshStats recomputes its stats to '{}', and the cleanup + // drops its last library_artist row — leaving the artist row alive but orphaned. RefreshStats + // must then mark it missing (see markOrphansMissing). + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways + + By("Starting from a library where Pink Floyd has its own single album") + floyd := template(_t{"artist": "Pink Floyd", "album": "The Wall", "year": 1979}) + fsys = createFS(fstest.MapFS{ + "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), + "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), + "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), + "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), + "Pink Floyd/The Wall/01 - Another Brick.mp3": floyd(track(1, "Another Brick in the Wall")), + }) + Expect(runScanner(ctx, true)).To(Succeed()) + + nonMissingArtists := func() []string { + aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"missing": false}}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(aa, func(a model.Artist) string { return a.Name }) + } + orphanCount := func() int64 { + var n int64 + Expect(db.Db().QueryRowContext(ctx, + "SELECT count(*) FROM artist WHERE missing = false "+ + "AND id NOT IN (SELECT artist_id FROM library_artist)").Scan(&n)).To(Succeed()) + return n + } + // Read the artist row directly: selectArtist inner-joins library_artist, so an orphan never + // surfaces through the repository. Returns a descriptive string for clear test failures. + floydState := func() string { + var m bool + err := db.Db().QueryRowContext(ctx, + "SELECT missing FROM artist WHERE name = 'Pink Floyd'").Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return "NOT_FOUND" + } + Expect(err).ToNot(HaveOccurred()) + if m { + return "MISSING" + } + return "PRESENT" + } + + By("Confirming Pink Floyd is visible after the import, with no orphan") + Expect(nonMissingArtists()).To(ContainElement("Pink Floyd")) + Expect(floydState()).To(Equal("PRESENT")) + Expect(orphanCount()).To(BeZero()) + + By("Removing all of Pink Floyd's files and rescanning") + fsys.Remove("Pink Floyd/The Wall/01 - Another Brick.mp3") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Checking Pink Floyd's row survives but is marked missing, leaving no orphan") + Expect(floydState()).To(Equal("MISSING")) + Expect(orphanCount()).To(BeZero()) + // The Beatles keep their content, so the fix must not over-mark them. + Expect(nonMissingArtists()).To(ContainElement("The Beatles")) + }) + It("does not override artist fields when importing an undertagged file", func() { By("Making sure artist in the DB contains MBID and sort name") aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{ diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index e6a694f2b..887344b1b 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -5,11 +5,13 @@ import ( "io/fs" "maps" "path" + "path/filepath" "slices" "sort" "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -123,9 +125,6 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath) continue } - if isEntryIgnored(entry.Name()) { - continue - } if ctx.Err() != nil { return folder, children, ctx.Err() } @@ -135,7 +134,10 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) continue } - if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) { + if isIgnoredEntry(entry.Name(), isDir) { + continue + } + if isDir && isDirReadable(ctx, job.fs, entryPath) { children = append(children, entryPath) folder.numSubFolders++ } else { @@ -147,12 +149,16 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC if fileInfo.ModTime().After(folder.modTime) { folder.modTime = fileInfo.ModTime() } + name, ok := resolveEntryName(ctx, job.fs, dirPath, entry) + if !ok { + continue + } switch { - case model.IsAudioFile(entry.Name()): + case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry - case model.IsValidPlaylist(entry.Name()): + case model.IsValidPlaylist(name): folder.numPlaylists++ - case model.IsImageFile(entry.Name()): + case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) } @@ -213,6 +219,59 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, return fileInfo.IsDir(), nil } +const maxSymlinkHops = 40 + +// resolveEntryName returns the name to classify the entry by, and whether to +// consider it at all. Symlinks are resolved to their final target so the caller +// classifies by the target's extension, not the link's name. Returns ok=false +// when symlinks are disabled or the target can't be resolved. +func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) { + if entry.Type()&fs.ModeSymlink == 0 { + return entry.Name(), true + } + linkPath := path.Join(dirPath, entry.Name()) + if !conf.Server.Scanner.FollowSymlinks { + log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) + return "", false + } + // OS-backed filesystems can resolve the whole chain, even when it leaves the FS root + // (e.g. a link into another folder/drive), so the final target is always what gets + // classified. The fs.ReadLink loop below can't see past the root: it classifies by the + // last in-chain name it can reach. + if resolver, ok := fsys.(storage.SymlinkResolverFS); ok { + target, err := resolver.ResolveSymlink(linkPath) + if err != nil { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := filepath.Base(target) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", target, "name", resolved) + return resolved, true + } + cur := linkPath + for hop := 0; hop < maxSymlinkHops; hop++ { + target, err := fs.ReadLink(fsys, cur) + if err != nil { + if hop == 0 { + log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) + return "", false + } + resolved := path.Base(cur) + log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved) + return resolved, true + } + if path.IsAbs(target) { + // Absolute targets are not valid fs.FS paths, so the next ReadLink fails and + // resolution stops here, leaving cur as the target to classify by name. + cur = target + } else { + cur = path.Join(path.Dir(cur), target) + } + } + log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath) + return "", false +} + // isDirReadable returns true if the directory represented by dirEnt is readable func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool { dir, err := fsys.Open(dirPath) @@ -233,22 +292,35 @@ var ignoredDirs = []string{ "#snapshot", "@Recycle", "@Recently-Snapshot", + ".git", ".streams", "lost+found", } -// isDirIgnored returns true if the directory represented by dirEnt should be ignored -func isDirIgnored(name string) bool { - // allows Album folders for albums which eg start with ellipses - if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") { +// isIgnoredEntry returns true if a directory entry with the given name should be +// skipped during scanning. It centralizes all name- and type-based ignore policy: +// - special system directories in ignoredDirs are always ignored; +// - dot-prefixed files are always ignored; +// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled, +// allowing albums like ".Hack Sign" to be scanned when the option is off. +func isIgnoredEntry(name string, isDir bool) bool { + if isDir && isDirIgnored(name) { return true } - if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) { - return true - } - return false + return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders) } -func isEntryIgnored(name string) bool { - return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") +// isDirIgnored returns true if the directory name is in the explicit ignoredDirs +// blocklist. Used both while walking the tree and by the file watcher. +func isDirIgnored(name string) bool { + return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) +} + +// isDotEntry returns true only for names with exactly one leading dot (the +// convention for hidden entries), e.g. ".hidden". Names with two or more leading +// dots are not considered hidden: "." and ".." are the special self/parent +// references, and anything like "..foo" or "...Album" is a regular name (album +// folders sometimes start with ellipses), so all of these return false. +func isDotEntry(name string) bool { + return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 42b7af7ba..9fb650c4d 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -45,6 +45,14 @@ var _ = Describe("walk_dir_tree", func() { "root/d/f3.mp3": {}, "root/e/original/f1.mp3": {}, "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, + "root/f/realsong.mp3": {Data: []byte("AUDIO")}, + "root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")}, + "root/f/secret": {Data: []byte("TOPSECRET")}, + "root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")}, + "root/g/.Hack Sign Original Soundtrack/track.mp3": {}, + "root/h/.hidden.mp3": {}, + "root/i/.git/config": {}, + "root/i/.streams/stream.mp3": {}, }, } job = &scanJob{ @@ -93,15 +101,52 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders).ToNot(HaveKey("root/d")) + // By default (Scanner.IgnoreDotFolders == true), dot-prefixed + // folders are skipped, dot-prefixed files are not indexed, and + // the special ignoredDirs (.git, .streams) are never traversed. + Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack")) + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + // Symlink specific checks if followSymlinks { Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) + Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } else { Expect(folders).ToNot(HaveKey("root/e/symlink")) + Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3")) + Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } }, - Entry("with symlinks enabled", true, 7), - Entry("with symlinks disabled", false, 6), + Entry("with symlinks enabled", true, 11), + Entry("with symlinks disabled", false, 10), + ) + + DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled", + func(followSymlinks bool) { + conf.Server.Scanner.FollowSymlinks = followSymlinks + conf.Server.Scanner.IgnoreDotFolders = false + folders := getFolders() + + // Dot-prefixed album folders are now traversed and indexed + Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll( + HaveLen(1), + HaveKey("track.mp3"), + )) + + // Dot-prefixed files are still ignored, even with the flag off + Expect(folders["root/h"].audioFiles).To(BeEmpty()) + + // Special ignoredDirs remain blocked regardless of the flag + Expect(folders).ToNot(HaveKey("root/i/.git")) + Expect(folders).ToNot(HaveKey("root/i/.streams")) + }, + Entry("with symlinks enabled", true), + Entry("with symlinks disabled", false), ) }) @@ -264,19 +309,310 @@ var _ = Describe("walk_dir_tree", func() { }) }) + Describe("resolveEntryName", func() { + var fsys fs.FS + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + fsys = fstest.MapFS{ + "dir/real.mp3": {Data: []byte("AUDIO")}, + "dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")}, + "dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, + "dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")}, + "dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")}, + "dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")}, + "dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")}, + } + }) + + resolve := func(name string) (string, bool) { + entries, err := fs.ReadDir(fsys, "dir") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, "dir", e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("with symlinks enabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a direct symlink to its audio target name", func() { + name, ok := resolve("audio.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink CHAIN to the final target name", func() { + name, ok := resolve("chain.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("resolves a symlink to a non-audio target name (so caller can reject it)", func() { + name, ok := resolve("evil.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("passwd")) + }) + It("rejects a symlink loop", func() { + _, ok := resolve("loop1.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + Context("with symlinks disabled", func() { + BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false }) + + It("returns the entry name for a plain file", func() { + name, ok := resolve("real.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("real.mp3")) + }) + It("skips any file symlink", func() { + _, ok := resolve("audio.mp3") + Expect(ok).To(BeFalse()) + }) + }) + }) + + Describe("symlink chain (real fs)", func() { + BeforeEach(func() { + tests.SkipOnWindows("symlink semantics") + DeferCleanup(configtest.SetupConfig()) + }) + + classify := func(fsys fs.FS, dirPath, name string) (string, bool) { + entries, err := fs.ReadDir(fsys, dirPath) + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + if e.Name() == name { + return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e) + } + } + Fail("entry not found: " + name) + return "", false + } + + Context("committed 3-level fixtures", func() { + // tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures". + var fsys fs.FS + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + wd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + fsys = os.DirFS(wd) + }) + + It("keeps a 3-level chain that resolves to real audio", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("test.mp3")) + Expect(model.IsAudioFile(name)).To(BeTrue()) + }) + + It("rejects a 3-level chain that resolves to a non-audio file", func() { + name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeTrue()) + Expect(name).To(Equal("index.html")) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips the chain entirely when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + _, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") + Expect(ok).To(BeFalse()) + _, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") + Expect(ok).To(BeFalse()) + }) + }) + + // Regression for #5752: the production localFS must resolve file symlinks. + // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based + // resolution is not available and full OS-level resolution is required. + Context("production local storage FS", func() { + var libRoot string + var musicFS storage.MusicFS + + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + + // Reproduces the reported layout: a "pool" with the real files and a + // library containing only symlinks into the pool. + base := GinkgoT().TempDir() + pool := filepath.Join(base, "pool") + libRoot = filepath.Join(base, "userlib") + Expect(os.MkdirAll(pool, 0755)).To(Succeed()) + Expect(os.MkdirAll(libRoot, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pool, "real.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pool, "secrets.txt"), []byte("TOPSECRET"), 0600)).To(Succeed()) + // mid.wav lives OUTSIDE the library and has an audio name, but points at a + // non-audio file. A chain through it must be classified by the FINAL target. + Expect(os.Symlink(filepath.Join(pool, "secrets.txt"), filepath.Join(pool, "mid.wav"))).To(Succeed()) + + Expect(os.Symlink("../pool/real.mp3", filepath.Join(libRoot, "relative.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "real.mp3"), filepath.Join(libRoot, "absolute.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed()) + + u, err := storage.LocalPathToURL(libRoot) + Expect(err).ToNot(HaveOccurred()) + s, err := storage.For(u.String()) + Expect(err).ToNot(HaveOccurred()) + musicFS, err = s.FS() + Expect(err).ToNot(HaveOccurred()) + }) + + walkRoot := func() *folderEntry { + job := &scanJob{fs: musicFS, lib: model.Library{Path: libRoot}} + results, err := walkDirTree(GinkgoT().Context(), job) + Expect(err).ToNot(HaveOccurred()) + var root *folderEntry + for folder := range results { + if folder.path == "." { + root = folder + } + } + Expect(root).ToNot(BeNil()) + return root + } + + It("imports symlinks to out-of-library audio files", func() { + root := walkRoot() + Expect(root.audioFiles).To(HaveKey("relative.mp3")) + Expect(root.audioFiles).To(HaveKey("absolute.mp3")) + }) + + It("rejects a chain that ends in a non-audio file, even through an audio-named intermediate", func() { + root := walkRoot() + Expect(root.audioFiles).ToNot(HaveKey("evil.wav")) + }) + + It("skips broken symlinks", func() { + root := walkRoot() + Expect(root.audioFiles).ToNot(HaveKey("broken.mp3")) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + root := walkRoot() + Expect(root.audioFiles).To(BeEmpty()) + }) + }) + + Context("out-of-tree escape (temp dir)", func() { + var root string + BeforeEach(func() { + conf.Server.Scanner.FollowSymlinks = true + root = GinkgoT().TempDir() + outside := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) + // evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink. + Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed()) + Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed()) + }) + + It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() { + fsys := os.DirFS(root) + + name, ok := classify(fsys, ".", "song.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "legit.flac") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeTrue()) + + name, ok = classify(fsys, ".", "evil.mp3") + Expect(ok).To(BeTrue()) + Expect(model.IsAudioFile(name)).To(BeFalse()) + }) + + It("skips all file symlinks when FollowSymlinks is disabled", func() { + conf.Server.Scanner.FollowSymlinks = false + fsys := os.DirFS(root) + entries, err := fs.ReadDir(fsys, ".") + Expect(err).ToNot(HaveOccurred()) + for _, e := range entries { + _, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e) + if e.Type()&fs.ModeSymlink != 0 { + Expect(ok).To(BeFalse(), e.Name()) + } else { + Expect(ok).To(BeTrue(), e.Name()) + } + } + }) + }) + }) + Describe("isDirIgnored", func() { DescribeTable("returns expected result", func(dirName string, expected bool) { Expect(isDirIgnored(dirName)).To(Equal(expected)) }, Entry("normal dir", "empty_folder", false), - Entry("hidden dir", ".hidden_folder", true), + Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false), + Entry("git dir", ".git", true), + Entry("streams dir", ".streams", true), Entry("dir starting with ellipsis", "...unhidden_folder", false), Entry("recycle bin", "$Recycle.Bin", true), Entry("snapshot dir", "#snapshot", true), ) }) + Describe("isIgnoredEntry", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("with IgnoreDotFolders enabled (default)", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = true + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder", ".Hack Sign Original Soundtrack", true, true), + Entry("dot file", ".hidden.mp3", false, true), + Entry("blocklisted dir", ".git", true, true), + Entry("ellipsis dir", "...unhidden", true, false), + ) + + DescribeTable("with IgnoreDotFolders disabled", + func(name string, isDir, expected bool) { + conf.Server.Scanner.IgnoreDotFolders = false + Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) + }, + Entry("normal dir", "Album", true, false), + Entry("normal file", "track.mp3", false, false), + Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false), + Entry("dot file is still ignored", ".hidden.mp3", false, true), + Entry("blocklisted dir still ignored", ".git", true, true), + ) + }) + + Describe("isDotEntry", func() { + DescribeTable("returns expected result", + func(name string, expected bool) { + Expect(isDotEntry(name)).To(Equal(expected)) + }, + Entry("dot folder", ".Hidden", true), + Entry("dot file", ".hidden.mp3", true), + Entry("current dir", ".", false), + Entry("parent dir", "..", false), + Entry("two leading dots", "..foo", false), + Entry("ellipsis", "...unhidden", false), + Entry("normal name", "Album", false), + ) + }) + Describe("fullReadDir", func() { var ( fsys fakeFS @@ -414,3 +750,30 @@ func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) { } return nil, fmt.Errorf("not a directory") } + +// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS). +func (m *mockMusicFS) ReadLink(name string) (string, error) { + mapFS := m.FS.(fstest.MapFS) + entry, ok := mapFS[name] + if !ok { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist} + } + if entry.Mode&fs.ModeSymlink == 0 { + return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")} + } + return string(entry.Data), nil +} + +// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS). +func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) { + mapFS := m.FS.(fstest.MapFS) + if _, ok := mapFS[name]; !ok { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} + } + f, err := m.FS.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Stat() +} diff --git a/scanner/watcher.go b/scanner/watcher.go index 376db910c..baf94b79b 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "path/filepath" + "strings" "sync" "time" @@ -320,18 +321,35 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music } func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { - baseDir, name := filepath.Split(path) + _, name := filepath.Split(path) + // A change anywhere inside an ignored directory (a dot-folder when + // Scanner.IgnoreDotFolders is enabled, or a special system folder) must not + // trigger a scan, even for media files: the scan would skip it anyway. + if isUnderIgnoredDir(path) { + return true + } switch { - case model.IsAudioFile(path): - return false - case model.IsValidPlaylist(path): - return false - case model.IsImageFile(path): - return false + case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path): + // A media file is normally not ignored, but a dot-prefixed one (e.g. + // ".hidden.mp3") is always skipped by the scanner, so don't scan for it. + return isDotEntry(name) case name == ".DS_Store": return true } - // As it can be a deletion and not a change, we cannot reliably know if the path is a file or directory. - // But at this point, we can assume it's a directory. If it's a file, it would be ignored anyway - return isDirIgnored(baseDir) + // As it can be a deletion and not a change, we cannot reliably know if the + // path is a file or directory. But at this point, we can assume it's a + // directory. If it's a file, it would be ignored anyway. + return isIgnoredEntry(name, true) +} + +// isUnderIgnoredDir returns true if any parent directory component of the given +// path is an ignored directory, reusing the same policy as the scanner walk. +func isUnderIgnoredDir(path string) bool { + dir, _ := filepath.Split(path) + for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") { + if part != "" && isIgnoredEntry(part, true) { + return true + } + } + return false } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 9795129b0..15e49e195 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -30,10 +31,14 @@ var _ = Describe("Watcher", func() { ctx, cancel = context.WithCancel(GinkgoT().Context()) DeferCleanup(cancel) + // Use a fake storage scheme: watchLibrary goroutines spawned by Run/Watch are not + // joined on spec teardown, and the real file:// storage reads conf.Server on + // construction, racing with the configtest cleanup that restores the config. + storagetest.Register("fake-watcher", &storagetest.FakeFS{}) lib = &model.Library{ ID: 1, Name: "Test Library", - Path: "/test/library", + Path: "fake-watcher:///test/library", } // Set up mocks @@ -234,7 +239,7 @@ var _ = Describe("Watcher", func() { lib2 = &model.Library{ ID: 2, Name: "Test Library 2", - Path: "/test/library2", + Path: "fake-watcher:///test/library2", } mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo) @@ -428,6 +433,50 @@ var _ = Describe("Watcher", func() { Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder") }) }) + + }) +}) + +var _ = Describe("isIgnoredPath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Context("with IgnoreDotFolders enabled (default)", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = true + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file in normal folder", "rock/Album/track.mp3", false), + Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true), + Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true), + Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true), + Entry("media file inside .git", "rock/.git/track.mp3", true), + Entry("dot-folder itself", "rock/.Hidden Album", true), + Entry("normal folder itself", "rock/Album", false), + Entry(".DS_Store file", "rock/Album/.DS_Store", true), + ) + }) + + Context("with IgnoreDotFolders disabled", func() { + BeforeEach(func() { + conf.Server.Scanner.IgnoreDotFolders = false + }) + + DescribeTable("returns expected result", + func(p string, expected bool) { + Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) + }, + Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false), + Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true), + Entry("dot-folder itself is allowed", "rock/.Hidden Album", false), + Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true), + Entry(".git still ignored", "rock/.git/config", true), + ) }) }) diff --git a/scheduler/crontab_schedule_test.go b/scheduler/crontab_schedule_test.go index b1e26f1de..b616f0884 100644 --- a/scheduler/crontab_schedule_test.go +++ b/scheduler/crontab_schedule_test.go @@ -185,7 +185,7 @@ var _ = Describe("ParseCrontab", func() { // findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63). func findSetBit(v uint64) int { v &^= 1 << 63 // clear starBit - for i := 0; i < 63; i++ { + for i := range 63 { if v&(1<")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) + }, + Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), + Entry("embedded plain text", "Embedded Plain"), + Entry("embedded TTML", "Embedded TTML"), + Entry("LRC sidecar", "Sidecar LRC"), + Entry("SRT sidecar", "Sidecar SRT"), + Entry("YAML sidecar", "Sidecar YAML"), + ) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index a837da124..e652cf492 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -142,7 +142,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go index 4a5c35a7e..d8c5d3689 100644 --- a/server/e2e/subsonic_multiuser_test.go +++ b/server/e2e/subsonic_multiuser_test.go @@ -60,15 +60,23 @@ var _ = Describe("Multi-User Isolation", Ordered, func() { }) }) - Describe("getUsers for regular user", func() { - It("returns only the requesting user's info", func() { - resp := doReqWithUser(regularUser, "getUsers") + Describe("getUsers authorization", func() { + It("succeeds for admin user", func() { + resp := doReqWithUser(adminUser, "getUsers") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Users).ToNot(BeNil()) Expect(resp.Users.User).To(HaveLen(1)) - Expect(resp.Users.User[0].Username).To(Equal("regular")) - Expect(resp.Users.User[0].AdminRole).To(BeFalse()) + Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) + Expect(resp.Users.User[0].AdminRole).To(BeTrue()) + }) + + It("fails for regular user because getUsers is admin-only", func() { + resp := doReqWithUser(regularUser, "getUsers") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) }) }) }) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 466e68cf0..467535df7 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -646,5 +646,43 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { stringResp := doReq("getPlaylist", "id", stringPls.ID) Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) }) + + DescribeTable("isMissing/isPresent partition all songs for nullable column fields", + func(fieldName string) { + allPls := &model.Playlist{ + Name: "All Songs " + fieldName, + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, + } + Expect(ds.Playlist(ctx).Put(allPls)).To(Succeed()) + missingPls := &model.Playlist{ + Name: "Missing " + fieldName, + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}}, + } + Expect(ds.Playlist(ctx).Put(missingPls)).To(Succeed()) + presentPls := &model.Playlist{ + Name: "Present " + fieldName, + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}}, + } + Expect(ds.Playlist(ctx).Put(presentPls)).To(Succeed()) + + allResp := doReq("getPlaylist", "id", allPls.ID) + missingResp := doReq("getPlaylist", "id", missingPls.ID) + presentResp := doReq("getPlaylist", "id", presentPls.ID) + + Expect(allResp.Status).To(Equal(responses.StatusOK)) + Expect(allResp.Playlist.SongCount).To(BeNumerically(">", int32(0))) + Expect(missingResp.Playlist.SongCount + presentResp.Playlist.SongCount). + To(Equal(allResp.Playlist.SongCount)) + }, + Entry("bpm", "bpm"), + Entry("bitdepth", "bitdepth"), + Entry("lyrics", "lyrics"), + Entry("mbz_recording_id", "mbz_recording_id"), + Entry("album", "album"), + Entry("comment", "comment"), + ) }) }) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go index ce64c31a1..cd778fa79 100644 --- a/server/e2e/subsonic_radio_test.go +++ b/server/e2e/subsonic_radio_test.go @@ -46,6 +46,30 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(radioID).ToNot(BeEmpty()) }) + It("getInternetRadioStations remains available to regular users", func() { + resp := doReqWithUser(regularUser, "getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + + It("createInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "createInternetRadioStation", + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + It("updateInternetRadioStation modifies the station", func() { resp := doReq("updateInternetRadioStation", "id", radioID, @@ -64,6 +88,35 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) }) + It("updateInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) + Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) + }) + + It("deleteInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID)) + }) + It("deleteInternetRadioStation removes it", func() { resp := doReq("deleteInternetRadioStation", "id", radioID) diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index e348bc6b9..00b60ad6f 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(6)) - Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(14)) + Expect(resp.SearchResult3.Artist).To(HaveLen(7)) + Expect(resp.SearchResult3.Album).To(HaveLen(8)) + Expect(resp.SearchResult3.Song).To(HaveLen(20)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_sharing_test.go b/server/e2e/subsonic_sharing_test.go index 1a082ba0f..03bf1f80f 100644 --- a/server/e2e/subsonic_sharing_test.go +++ b/server/e2e/subsonic_sharing_test.go @@ -125,3 +125,82 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + +var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { + var userA, userB model.User + var shareID string + var albumID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + userA = createUser("share-user-a", "share-user-a", "Share User A", false) + userB = createUser("share-user-b", "share-user-b", "Share User B", false) + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + shareID = resp.Shares.Share[0].ID + Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName)) + }) + + It("userB's getShares does not leak userA's share", func() { + resp := doReqWithUser(userB, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("userA still sees own share", func() { + resp := doReqWithUser(userA, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].ID).To(Equal(shareID)) + Expect(resp.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("admin sees userA's share", func() { + resp := doReqWithUser(adminUser, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + ids := make([]string, len(resp.Shares.Share)) + for i, s := range resp.Shares.Share { + ids[i] = s.ID + } + Expect(ids).To(ContainElement(shareID)) + }) + + It("userB cannot updateShare on userA's share", func() { + resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm description unchanged for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("userB cannot deleteShare on userA's share", func() { + resp := doReqWithUser(userB, "deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm share still present for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].ID).To(Equal(shareID)) + }) +}) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 40161470b..1b8d34eb1 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -47,7 +47,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(nil), + lyrics.NewLyrics(ds, nil), decider, sonicSvc, ) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index ae3d6208c..afe7d52ca 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -159,11 +159,22 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(ds.Player(ctx).Put(player)).To(Succeed()) } + setPlayerForcedFormat := func(format string) { + doReq("ping") + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") + Expect(err).ToNot(HaveOccurred()) + trc, err := ds.Transcoding(ctx).FindByFormat(format) + Expect(err).ToNot(HaveOccurred()) + player.TranscodingId = trc.ID + Expect(ds.Player(ctx).Put(player)).To(Succeed()) + } + AfterEach(func() { // Reset player MaxBitRate to 0 after each test player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") if err == nil { player.MaxBitRate = 0 + player.TranscodingId = "" _ = ds.Player(ctx).Put(player) } }) @@ -396,30 +407,34 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap is ignored", func() { - It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is enforced", func() { + It("forces transcode when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps, player cap is 320, but getTranscodeDecision - // ignores server-side overrides — client profiles are used as-is + // FLAC is 900kbps. Player cap (320) < source → direct play is + // rejected and the file is transcoded down. resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + // Target bitrate is capped at the player MaxBitRate (320kbps). + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) - It("uses only client limit, not player MaxBitRate", func() { + It("uses the player cap when it is more restrictive than the client limit", func() { setPlayerMaxBitRate(192) // 192 kbps player cap - // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 - // but getTranscodeDecision ignores player cap → client limit (320kbps) applies + // Client caps at 320kbps (bitrateCapClient); player is more + // restrictive at 192 → player cap wins. resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Only client limit (320kbps) applies → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Player cap (192kbps) applies → 192000 bps. + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) @@ -475,38 +490,73 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { - It("does not inject maxAudioBitrate from player cap", func() { + Describe("player MaxBitRate injected by getTranscodeDecision", func() { + It("injects the player cap as the transcode target when the client declares none", func() { setPlayerMaxBitRate(320) - // opusTranscodeClient has no client bitrate limits - // Player cap is 320, but getTranscodeDecision ignores it - // FLAC (900kbps) → can't direct play → transcode to opus using format default + // opusTranscodeClient has no client bitrate limits. The player + // cap (320) is injected, so FLAC (900kbps) → opus is capped at 320. resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // Bitrate should be opus format default (128kbps), not player cap (320kbps) - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) + // Bitrate is the player cap (320kbps), not the opus format default. + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) - It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { + It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) - // Player cap is 320, but getTranscodeDecision ignores it - // Only client maxTranscodingAudioBitrate=192 applies + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps). + // Player cap (320) is higher → the lower client limit wins. resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate=192 → 192000 bps + // Client limit (192kbps) wins → 192000 bps. Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) }) + + Describe("player forced format", func() { + It("transcodes a FLAC to the forced opus format when the client supports it", func() { + setPlayerForcedFormat("opus") + + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + }) + + It("falls back to negotiation when the client does not support the forced format", func() { + setPlayerForcedFormat("opus") + + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + }) + + It("applies maxBitRate on top of the forced format", func() { + setPlayerForcedFormat("opus") + setPlayerMaxBitRate(96) + + resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(96000))) + }) + }) }) Describe("getTranscodeStream", func() { diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 1b78bb93e..daa918d00 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -45,8 +45,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { return err } ar.UploadedImage = filename - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } @@ -65,8 +64,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { return err } ar.UploadedImage = "" - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 02626a4ee..cfecfa663 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -97,7 +97,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Marshal the actual configuration struct to preserve original field names - configBytes, err := json.Marshal(*conf.Server) + configBytes, err := json.Marshal(conf.Server) if err != nil { log.Error(ctx, "Error marshaling config", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 4e6e9e89b..107b01e01 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -25,6 +25,7 @@ var _ = Describe("Config API", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index ed5564a41..9b7061845 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -27,6 +28,7 @@ var _ = Describe("Library API", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..5a7023eb6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -72,7 +72,8 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - api.R(r, "/tag", model.Tag{}, true) + api.R(r, "/tag", model.Tag{}, false) + api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) } diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index f0ee50ebb..b1ed09d65 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -32,6 +32,7 @@ var _ = Describe("Song Endpoints", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute // Setup mock repositories diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index e1c933709..9bf502687 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -76,6 +76,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute plsSvc = &mockPlaylistsService{} diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 8fc88e09c..aa91a7951 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -29,6 +29,7 @@ var _ = Describe("Plugin API", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false conf.Server.Plugins.Enabled = true ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go index ef971ee68..0aad09718 100644 --- a/server/nativeapi/queue_test.go +++ b/server/nativeapi/queue_test.go @@ -9,7 +9,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,7 +31,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("POST /queue", func() { It("saves the queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -50,7 +49,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("saves an empty queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))} + payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -63,7 +62,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (negative)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -75,7 +74,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (too large)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -97,7 +96,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -166,7 +165,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("PUT /queue", func() { It("updates the queue fields", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}} - payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))} + payload := updateQueuePayload{Current: new(2), Position: new(int64(20))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -184,7 +183,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates only ids", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -198,7 +197,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates ids and current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -213,7 +212,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when new ids invalidate current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -225,7 +224,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when current out of bounds", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} - payload := updateQueuePayload{Current: gg.P(3)} + payload := updateQueuePayload{Current: new(3)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -246,7 +245,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Position: gg.P(int64(10))} + payload := updateQueuePayload{Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 18bfcc01c..76f674483 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,6 +97,22 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { return &s } +// encodeMediafileShare builds the signed token embedded in a public share link +// for a single track. +// +// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication. +// The token is a signed, opaque capability that identifies one shared track +// (plus its transcode format/bitrate and the parent share id). We use a JWT here +// (reusing the library we already have) because it is a simple way to get three +// properties for a public link: the embedded ids can't be enumerated by guessing, +// the signature +// makes the claims tamper-evident, and the self-contained exp lets us reject +// stale links without a DB lookup. It carries no user identity (no subject, no +// admin flag) and grants access to nothing beyond the share it belongs to; the +// stream handler still verifies the share exists, is unexpired, and that the +// track is actually a member of it. An attacker who can forge these tokens +// necessarily already holds the signing secret, which also signs real user +// sessions, so that scenario is out of scope for the share boundary specifically. func encodeMediafileShare(s model.Share, id string) string { claims := auth.Claims{ ID: id, diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 7d5a836b3..15abab693 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -3,11 +3,12 @@ package public import ( "errors" "net/http" + "slices" "strconv" "time" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/stream" + streampkg "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" @@ -25,16 +26,20 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - if info.shareID != "" { - share, err := pub.ds.Share(ctx).Get(info.shareID) - if err != nil { - checkShareError(ctx, w, err, info.shareID) - return - } - if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { - checkShareError(ctx, w, model.ErrExpired, info.shareID) - return - } + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + shareOwner, err := pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -48,10 +53,22 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{ + // 404 rather than 403 so the response doesn't reveal whether the id exists. + // The track must belong to the share AND be within the owner's libraries. + if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "not found", http.StatusNotFound) + return + } + + stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ Format: info.format, BitRate: info.bitrate, }) if err != nil { + if errors.Is(err, streampkg.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) + http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests) + return + } log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) return @@ -80,6 +97,15 @@ type shareTrackInfo struct { shareID string } +func shareContainsTrack(share *model.Share, mediaFileID string) bool { + return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool { + return mf.ID == mediaFileID + }) +} + +// decodeStreamInfo decodes the signed share-link token. This is a scoped +// public-share capability, not an auth credential; see encodeMediafileShare for +// why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { c, err := auth.Validate(tokenString) if err != nil { @@ -88,6 +114,9 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } + if c.ShareID == "" { + return shareTrackInfo{}, errors.New("required claim \"sid\" not found") + } return shareTrackInfo{ id: c.ID, format: c.Format, diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 222d5ef1a..2f32ea6f2 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -72,14 +71,11 @@ var _ = Describe("decodeStreamInfo", func() { Expect(err).To(HaveOccurred()) }) - It("handles tokens without shareID (backward compat)", func() { + It("rejects a token without a shareID claim", func() { claims := auth.Claims{ID: "mf-123", Format: "opus"} token, _ := auth.CreatePublicToken(claims) - info, err := decodeStreamInfo(token) - Expect(err).NotTo(HaveOccurred()) - Expect(info.id).To(Equal("mf-123")) - Expect(info.format).To(Equal("opus")) - Expect(info.shareID).To(BeEmpty()) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) }) }) @@ -89,7 +85,7 @@ var _ = Describe("encodeMediafileShare", func() { }) It("includes the share ID in the token", func() { - exp := P(time.Now().Add(time.Hour)) + exp := new(time.Now().Add(time.Hour)) s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} token := encodeMediafileShare(s, "mf-999") info, err := decodeStreamInfo(token) @@ -132,11 +128,22 @@ var _ = Describe("handleStream", func() { return w } - It("passes all validation and reaches the streamer for a valid token", func() { + shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + mfRepo.SetData(model.MediaFiles{mf}) ds.MockedMediaFile = mfRepo + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, + model.MediaFile{ID: "mf-123", Title: "Test Song"}, + ) claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) @@ -147,6 +154,52 @@ var _ = Describe("handleStream", func() { Expect(streamer.req.BitRate).To(Equal(192)) }) + It("returns 404 when the track is outside the share owner's libraries", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, + model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2}, + ) + + claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + + It("returns 404 when the track is not a member of the share", func() { + owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) + ds.MockedMediaFile = mfRepo + shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}} + + claims := auth.Claims{ID: "mf-other", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + + It("streams a track inside the share owner's libraries", func() { + shareOwnedBy( + model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, + model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1}, + ) + + claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + }) + It("returns 400 for an expired token", func() { claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) @@ -164,8 +217,7 @@ var _ = Describe("handleStream", func() { It("returns 410 when share has been set to expired", func() { shareRepo.ID = "share123" - expired := time.Now().Add(-time.Hour) - shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))} claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreatePublicToken(claims) @@ -181,12 +233,12 @@ var _ = Describe("handleStream", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) - It("skips share check for tokens without shareID (backward compat)", func() { + It("returns 400 for tokens without a shareID", func() { claims := auth.Claims{ID: "mf-123"} token, _ := auth.CreatePublicToken(claims) w := makeRequest(token) - // Should get past share check, then fail on media file lookup (no mock data) - Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(streamer.called).To(BeFalse()) }) It("returns 400 for an invalid token", func() { diff --git a/server/serve_index.go b/server/serve_index.go index 13fa4a9ce..a538daf1a 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -107,6 +107,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl addShareData(r, data, shareInfo) w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") err = t.Execute(w, data) if err != nil { log.Error(r, "Could not execute `index.html` template", err) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 0d82c8be9..24bbca960 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -240,10 +240,11 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.SongsByRandom(genre, fromYear, toYear) + opts := filter.SongsByGenreAndYearRange(genre, fromYear, toYear) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + opts.Max = size - songs, err := api.getSongs(r.Context(), 0, size, opts) + songs, err := api.ds.MediaFile(r.Context()).GetRandom(opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1ca364449..82e404228 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -7,7 +7,9 @@ import ( "fmt" "net/http" "regexp" + "strconv" + "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" @@ -171,12 +173,12 @@ func (api *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getUser", api.GetUser) - h(r, "getUsers", api.GetUsers) + h(r.With(adminOnly), "getUsers", api.GetUsers) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getScanStatus", api.GetScanStatus) - h(r, "startScan", api.StartScan) + h(r.With(adminOnly), "startScan", api.StartScan) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -195,10 +197,13 @@ func (api *Router) routes() http.Handler { }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) - h(r, "createInternetRadioStation", api.CreateInternetRadio) - h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) h(r, "getInternetRadioStations", api.GetInternetRadios) - h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + r.Group(func(r chi.Router) { + r.Use(adminOnly) + h(r, "createInternetRadioStation", api.CreateInternetRadio) + h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + }) }) if conf.Server.EnableSharing { r.Group(func(r chi.Router) { @@ -297,10 +302,12 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorMissingParameter, err.Error()) case errors.Is(err, req.ErrInvalidParam): err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): err = newError(responses.ErrorAuthorizationFail) + case errors.Is(err, stream.ErrTooManyTranscodes): + err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") default: err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) } @@ -310,15 +317,31 @@ func mapToSubsonicError(err error) subError { } func sendError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, stream.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(stream.RetryAfterSeconds)) + sendResponseWithStatus(w, r, errorResponse(err), http.StatusTooManyRequests) + return + } + sendResponse(w, r, errorResponse(err)) +} + +func errorResponse(err error) *responses.Subsonic { subErr := mapToSubsonicError(err) response := newResponse() response.Status = responses.StatusFailed response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()} - - sendResponse(w, r, response) + return response } func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { + sendResponseWithStatus(w, r, payload, 0) +} + +// sendResponseWithStatus writes the response body in the format requested by +// the client. When status is non-zero, WriteHeader is called with that code +// before the body is written; callers that need to set additional headers +// (e.g. Retry-After) must set them before calling. +func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic, status int) { p := req.Params(r) f, _ := p.String("f") var response []byte @@ -353,6 +376,9 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub sendError(w, r, err) return } + if status != 0 { + w.WriteHeader(status) + } if payload.Status == responses.StatusOK { if log.IsGreaterOrEqualTo(log.LevelTrace) { @@ -375,6 +401,10 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub } if _, err := w.Write(response); err != nil { //nolint:gosec - log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + if log.IsGreaterOrEqualTo(log.LevelTrace) { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + } else { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err) + } } } diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index a83f2f0eb..485daca58 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,9 +1,13 @@ package subsonic import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,16 @@ func TestSubsonicApi(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Subsonic API Suite") } + +// newLocalStorage fatals if the default extractor is not registered. +// Register a no-op so storage.For works in sidecar-lyrics tests. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &subsonicNoopExtractor{} + }) +}) + +type subsonicNoopExtractor struct{} + +func (e *subsonicNoopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *subsonicNoopExtractor) Version() string { return "noop" } diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index f3053c8af..f8d5b6642 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -1,18 +1,22 @@ package subsonic import ( + "context" "encoding/json" "encoding/xml" + "errors" + "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "golang.org/x/net/context" ) var _ = Describe("sendResponse", func() { @@ -136,7 +140,7 @@ var _ = Describe("sendResponse", func() { It("should return a fail response", func() { payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() @@ -153,6 +157,24 @@ var _ = Describe("sendResponse", func() { }) }) + It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() { + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/rest/stream", nil) + + sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes)) + + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) + + var subsonicResponse responses.Subsonic + err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed)) + Expect(subsonicResponse.Error).ToNot(BeNil()) + Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric)) + Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode")) + }) + It("updates status pointer when an error occurs", func() { pointer := int32(0) @@ -168,3 +190,24 @@ var _ = Describe("sendResponse", func() { Expect(pointer).To(Equal(responses.ErrorDataNotFound)) }) }) + +var _ = Describe("mapToSubsonicError", func() { + DescribeTable("maps repository errors to the correct Subsonic error code", + func(err error, expectedCode int32) { + subErr := mapToSubsonicError(err) + Expect(subErr.code).To(Equal(expectedCode)) + }, + Entry("rest.ErrPermissionDenied -> not authorized (50)", + rest.ErrPermissionDenied, responses.ErrorAuthorizationFail), + Entry("rest.ErrNotFound -> data not found (70)", + rest.ErrNotFound, responses.ErrorDataNotFound), + Entry("model.ErrNotAuthorized -> not authorized (50)", + model.ErrNotAuthorized, responses.ErrorAuthorizationFail), + Entry("model.ErrNotFound -> data not found (70)", + model.ErrNotFound, responses.ErrorDataNotFound), + Entry("wrapped rest.ErrPermissionDenied is still mapped", + fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail), + Entry("unknown error -> generic (0)", + errors.New("boom"), responses.ErrorGeneric), + ) +}) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 5b9c4f3c9..817238aaf 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -256,8 +256,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() - child := childFromMediaFile(ctx, *mf) - response.Song = &child + response.Song = new(childFromMediaFile(ctx, *mf)) return response, nil } diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 8ba4f0ff9..d19e163dd 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -90,10 +90,8 @@ func SongsByAlbum(albumId string) Options { }) } -func SongsByRandom(genre string, fromYear, toYear int) Options { - options := Options{ - Sort: "random", - } +func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { + options := Options{} ff := And{} if genre != "" { ff = append(ff, filterByGenre(genre)) @@ -108,21 +106,6 @@ func SongsByRandom(genre string, fromYear, toYear int) Options { return addDefaultFilters(options) } -func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { - return addDefaultFilters(Options{ - Sort: "lyrics, updated_at", - Order: "desc", - Max: 1, - Filters: And{ - Eq{"title": title}, - Or{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), - }, - }, - }) -} - func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options { if len(musicFolderIds) == 0 { return opts diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 74d57ade4..b986200ae 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -18,7 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -217,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = P(mf.BirthTime) + child.Created = new(mediaFileCreatedAt(mf)) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -251,7 +251,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.Comment = mf.Comment child.SortName = sortName(mf.SortTitle, mf.OrderTitle) - child.BPM = int32(mf.BPM) + child.BPM = int32(gg.V(mf.BPM)) child.MediaType = responses.MediaTypeSong child.MusicBrainzId = mf.MbzRecordingID child.Isrc = mf.Tags.Values(model.TagISRC) @@ -263,9 +263,10 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.ChannelCount = int32(mf.Channels) child.SamplingRate = int32(mf.SampleRate) - child.BitDepth = int32(mf.BitDepth) + child.BitDepth = int32(gg.V(mf.BitDepth)) child.Genres = toItemGenres(mf.Genres) child.Moods = mf.Tags.Values(model.TagMood) + child.Groupings = mf.Tags.Values(model.TagGrouping) child.DisplayArtist = mf.Artist child.Artists = artistRefs(mf.Participants[model.RoleArtist]) child.DisplayAlbumArtist = mf.AlbumArtist @@ -289,6 +290,12 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.Contributors = contributors child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus) + child.Works = slice.Map(mf.Works(), func(w model.Work) responses.Work { + return responses.Work{Name: w.Name, MusicBrainzId: w.MbzWorkID} + }) + child.Movements = slice.Map(mf.Movements(), func(m model.Movement) responses.Movement { + return responses.Movement{Name: m.Name, Number: m.Number, Count: m.Count} + }) return &child } @@ -319,18 +326,36 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } -// albumCreatedAt returns a best-effort timestamp for the album's `created` -// field, which is required by the OpenSubsonic spec but may be zero on legacy -// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all -// three are unset. +// albumCreatedAt mirrors the column used by recentlyAddedSort so clients can +// reproduce the "recently added" order locally: UpdatedAt when +// RecentlyAddedByModTime is set, CreatedAt otherwise. The other timestamps are +// fallbacks for legacy rows; returns zero only when all three are unset. func albumCreatedAt(al model.Album) time.Time { - if !al.CreatedAt.IsZero() { - return al.CreatedAt + candidates := []time.Time{al.CreatedAt, al.UpdatedAt, al.ImportedAt} + if conf.Server.RecentlyAddedByModTime { + candidates = []time.Time{al.UpdatedAt, al.CreatedAt, al.ImportedAt} } - if !al.UpdatedAt.IsZero() { - return al.UpdatedAt + for _, t := range candidates { + if !t.IsZero() { + return t + } } - return al.ImportedAt + return time.Time{} +} + +// mediaFileCreatedAt is the song counterpart of albumCreatedAt, tracking +// mediaFileRecentlyAddedSort; BirthTime is the legacy fallback. +func mediaFileCreatedAt(mf model.MediaFile) time.Time { + candidates := []time.Time{mf.CreatedAt, mf.UpdatedAt, mf.BirthTime} + if conf.Server.RecentlyAddedByModTime { + candidates = []time.Time{mf.UpdatedAt, mf.CreatedAt, mf.BirthTime} + } + for _, t := range candidates { + if !t.IsZero() { + return t + } + } + return time.Time{} } func childFromAlbum(ctx context.Context, al model.Album) responses.Child { @@ -345,7 +370,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() - child.Created = P(albumCreatedAt(al)) + child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -375,6 +400,7 @@ func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubson child.MusicBrainzId = al.MbzAlbumID child.Genres = toItemGenres(al.Genres) child.Moods = al.Tags.Values(model.TagMood) + child.Groupings = al.Tags.Values(model.TagGrouping) child.DisplayArtist = al.AlbumArtist child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist]) child.DisplayAlbumArtist = al.AlbumArtist @@ -440,7 +466,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = P(albumCreatedAt(album)) + dir.Created = albumCreatedAt(album) if album.Starred { dir.Starred = album.StarredAt } @@ -493,48 +519,6 @@ func mapExplicitStatus(explicitStatus string) string { return "" } -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { - lines := make([]responses.Line, len(lyrics.Line)) - - for i, line := range lyrics.Line { - lines[i] = responses.Line{ - Start: line.Start, - Value: line.Value, - } - } - - structured := responses.StructuredLyric{ - DisplayArtist: lyrics.DisplayArtist, - DisplayTitle: lyrics.DisplayTitle, - Lang: lyrics.Lang, - Line: lines, - Offset: lyrics.Offset, - Synced: lyrics.Synced, - } - - if structured.DisplayArtist == "" { - structured.DisplayArtist = mf.Artist - } - if structured.DisplayTitle == "" { - structured.DisplayTitle = mf.Title - } - - return structured -} - -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { - lyricList := make(responses.StructuredLyrics, len(lyricsList)) - - for i, lyrics := range lyricsList { - lyricList[i] = buildStructuredLyric(mf, lyrics) - } - - res := &responses.LyricsList{ - StructuredLyrics: lyricList, - } - return res -} - // getUserAccessibleLibraries returns the list of libraries the current user has access to. func getUserAccessibleLibraries(ctx context.Context) []model.Library { user := getUser(ctx) diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index abf6116f3..3741462a2 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "net/http/httptest" "time" @@ -358,6 +359,52 @@ var _ = Describe("helpers", func() { Expect(osChild).ToNot(BeNil()) Expect(osChild.Comment).To(Equal("Test Comment")) }) + + It("populates works and movements from tags", func() { + mf.Tags = model.Tags{ + model.TagWork: {"Symphony No. 5"}, + model.TagMusicBrainzWorkID: {"abc-123"}, + model.TagMovementName: {"I. Allegro"}, + model.TagMovementNumber: {"1"}, + model.TagMovementTotal: {"4"}, + } + osChild := osChildFromMediaFile(ctx, mf) + Expect(osChild).ToNot(BeNil()) + Expect(osChild.Works).To(Equal(responses.Array[responses.Work]{ + {Name: "Symphony No. 5", MusicBrainzId: "abc-123"}, + })) + Expect(osChild.Movements).To(Equal(responses.Array[responses.Movement]{ + {Name: "I. Allegro", Number: 1, Count: 4}, + })) + }) + + It("returns empty works and movements when no classical tags are present", func() { + osChild := osChildFromMediaFile(ctx, mf) + Expect(osChild).ToNot(BeNil()) + Expect(osChild.Works).To(BeEmpty()) + Expect(osChild.Movements).To(BeEmpty()) + }) + + It("serializes works and movements to spec-compliant JSON", func() { + mf.Tags = model.Tags{ + model.TagWork: {"Symphony No. 5"}, + model.TagMovementName: {"I. Allegro"}, + model.TagMovementNumber: {"1"}, + } + osChild := osChildFromMediaFile(ctx, mf) + data, err := json.Marshal(osChild) + Expect(err).ToNot(HaveOccurred()) + + var got map[string]any + Expect(json.Unmarshal(data, &got)).To(Succeed()) + // Required name present; optional musicBrainzId/count omitted (omitempty); number present. + Expect(got).To(HaveKeyWithValue("works", []any{ + map[string]any{"name": "Symphony No. 5"}, + })) + Expect(got).To(HaveKeyWithValue("movements", []any{ + map[string]any{"name": "I. Allegro", "number": float64(1)}, + })) + }) }) Context("when legacy clients list is empty", func() { @@ -572,34 +619,122 @@ var _ = Describe("helpers", func() { }) Describe("buildAlbumID3 Created field", func() { - It("uses CreatedAt when set", func() { - t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - al := model.Album{ID: "a1", Name: "A", CreatedAt: t} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(t)) + When("RecentlyAddedByModTime is false", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + }) + + It("uses CreatedAt when set", func() { + t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a1", Name: "A", CreatedAt: t} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(t)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(updated)) + }) + + It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(imported)) + }) + + It("leaves Created as zero time when all timestamps are zero", func() { + al := model.Album{ID: "a4", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created.IsZero()).To(BeTrue()) + }) }) - It("falls back to UpdatedAt when CreatedAt is zero", func() { - updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) - al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(updated)) + When("RecentlyAddedByModTime is true", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + }) + + It("uses UpdatedAt even when CreatedAt is also set", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) + al := model.Album{ID: "a5", Name: "A", CreatedAt: created, UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(updated)) + }) + + It("falls back to CreatedAt when UpdatedAt is zero", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a6", Name: "A", CreatedAt: created} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(created)) + }) + + It("falls back to ImportedAt when UpdatedAt and CreatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a7", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).To(Equal(imported)) + }) + + It("leaves Created as zero time when all timestamps are zero", func() { + al := model.Album{ID: "a8", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created.IsZero()).To(BeTrue()) + }) + }) + }) + + Describe("childFromMediaFile Created field", func() { + birth := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC) + + When("RecentlyAddedByModTime is false", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + }) + + It("uses CreatedAt, not BirthTime", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + mf := model.MediaFile{ID: "s1", BirthTime: birth, CreatedAt: created} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(created)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + mf := model.MediaFile{ID: "s2", BirthTime: birth, UpdatedAt: updated} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(updated)) + }) + + It("falls back to BirthTime when CreatedAt and UpdatedAt are zero", func() { + mf := model.MediaFile{ID: "s3", BirthTime: birth} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(birth)) + }) }) - It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { - imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) - al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(imported)) - }) + When("RecentlyAddedByModTime is true", func() { + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + }) - It("never leaves Created nil even when all timestamps are zero", func() { - al := model.Album{ID: "a4", Name: "A"} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) + It("uses UpdatedAt even when CreatedAt is also set", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) + mf := model.MediaFile{ID: "s4", BirthTime: birth, CreatedAt: created, UpdatedAt: updated} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(updated)) + }) + + It("falls back to CreatedAt when UpdatedAt is zero", func() { + created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + mf := model.MediaFile{ID: "s5", BirthTime: birth, CreatedAt: created} + child := childFromMediaFile(ctx, mf) + Expect(*child.Created).To(Equal(created)) + }) }) }) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index bac27f821..e6f64456d 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -40,10 +40,6 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorGeneric, "Internal error") } - if !loggedUser.IsAdmin { - return nil, newError(responses.ErrorAuthorizationFail) - } - p := req.Params(r) fullScan := p.BoolOr("fullScan", false) diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index c62c156bc..771fc3352 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -23,29 +23,6 @@ var _ = Describe("LibraryScanning", func() { }) Describe("StartScan", func() { - It("requires admin authentication", func() { - // Create non-admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "user-id", - IsAdmin: false, - }) - - // Create request - r := httptest.NewRequest("GET", "/rest/startScan", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return authorization error - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail)) - }) - It("triggers a full scan with no parameters", func() { // Create admin user ctx := request.WithUser(context.Background(), model.User{ diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go new file mode 100644 index 000000000..bfe1f1899 --- /dev/null +++ b/server/subsonic/lyrics.go @@ -0,0 +1,265 @@ +package subsonic + +import ( + "slices" + "sort" + "strings" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" +) + +// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal +// layer; its cue line is emitted before other agents sharing the same index. +const agentRoleMain = "main" + +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { + filtered := lyricsList + if !enhanced { + // Without enhanced, only return main-kind entries (a blank kind is main). + filtered = nil + for _, l := range lyricsList { + if l.IsMainKind() { + filtered = append(filtered, l) + } + } + } + + lyricList := make(responses.StructuredLyrics, len(filtered)) + for i, lyrics := range filtered { + lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) + } + return &responses.LyricsList{StructuredLyrics: lyricList} +} + +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { + agents := newLyricAgents(lyrics.Agents) + + lines := make([]responses.Line, len(lyrics.Line)) + var cueLines []responses.CueLine + for i, line := range lyrics.Line { + lines[i] = responses.Line{Start: line.Start, Value: line.Value} + if enhanced && len(line.Cue) > 0 { + cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...) + } + } + + structured := responses.StructuredLyric{ + DisplayArtist: lyrics.DisplayArtist, + DisplayTitle: lyrics.DisplayTitle, + Lang: lyrics.Lang, + Line: lines, + CueLine: cueLines, + Offset: lyrics.Offset, + Synced: lyrics.Synced, + } + + if enhanced { + structured.Kind = lyrics.EffectiveKind() + if len(cueLines) > 0 && len(agents.response) > 0 { + structured.Agents = agents.response + } + } + + if structured.DisplayArtist == "" { + structured.DisplayArtist = mf.Artist + } + if structured.DisplayTitle == "" { + structured.DisplayTitle = mf.Title + } + return structured +} + +// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and +// the response agent list reused without rescanning the slice per line. +type lyricAgents struct { + orderByID map[string]int + roleByID map[string]string + response []responses.Agent +} + +func newLyricAgents(agents []model.Agent) lyricAgents { + a := lyricAgents{ + orderByID: make(map[string]int, len(agents)), + roleByID: make(map[string]string, len(agents)), + response: make([]responses.Agent, 0, len(agents)), + } + for i, agent := range agents { + a.orderByID[agent.ID] = i + a.roleByID[agent.ID] = agent.Role + a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name}) + } + return a +} + +// buildCueLines splits a line's cues by agent and emits one CueLine per agent, +// ordered main-role first then by the agent's declared order. +func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine { + agentOrder := make([]string, 0, 2) + cuesByAgent := make(map[string][]model.Cue) + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + agentID := strings.TrimSpace(cue.AgentID) + if _, exists := cuesByAgent[agentID]; !exists { + agentOrder = append(agentOrder, agentID) + } + cuesByAgent[agentID] = append(cuesByAgent[agentID], cue) + } + + sort.SliceStable(agentOrder, func(i, j int) bool { + return agents.less(agentOrder[i], agentOrder[j], i, j) + }) + + cueLines := make([]responses.CueLine, 0, len(agentOrder)) + for _, agentID := range agentOrder { + value := line.Value + cues := cuesByAgent[agentID] + if len(agentOrder) > 1 { + value, cues = buildAgentCueLineValue(line.Value, cues, line.Cue, agentID) + } + cueLine := responses.CueLine{ + Index: index, + Start: line.Start, + End: line.End, + Value: value, + Cue: buildLyricCues(cues, line.End), + } + if agentID != "" { + cueLine.AgentID = agentID + } + cueLines = append(cueLines, cueLine) + } + return cueLines +} + +// less orders two agent IDs: the main role wins, then the declared agent order, +// then known-before-unknown, then the original encounter order (origI/origJ). +func (a lyricAgents) less(left, right string, origI, origJ int) bool { + leftMain := a.roleByID[left] == agentRoleMain + rightMain := a.roleByID[right] == agentRoleMain + if leftMain != rightMain { + return leftMain + } + + leftOrder, leftOK := a.orderByID[left] + rightOrder, rightOK := a.orderByID[right] + if leftOK && rightOK && leftOrder != rightOrder { + return leftOrder < rightOrder + } + if leftOK != rightOK { + return leftOK + } + return origI < origJ +} + +func buildAgentCueLineValue(lineValue string, cues, allCues []model.Cue, agentID string) (string, []model.Cue) { + if len(cues) == 0 { + return "", nil + } + + remapped := slices.Clone(cues) + var value strings.Builder + leadingGap := cueLineGap(lineValue, 0, remapped[0].ByteStart, allCues, agentID) + if strings.TrimSpace(leadingGap) != "" { + value.WriteString(leadingGap) + } + + previousEnd := -1 + for i := range remapped { + originalStart := remapped[i].ByteStart + originalEnd := remapped[i].ByteEnd + if i > 0 { + value.WriteString(cueLineGap(lineValue, previousEnd+1, originalStart, allCues, agentID)) + } + + remapped[i].ByteStart = value.Len() + value.WriteString(remapped[i].Value) + remapped[i].ByteEnd = value.Len() - 1 + previousEnd = originalEnd + } + + trailingGap := cueLineGap(lineValue, previousEnd+1, len(lineValue), allCues, agentID) + if strings.TrimSpace(trailingGap) != "" { + value.WriteString(trailingGap) + } + return value.String(), remapped +} + +type byteRange struct { + start int + end int +} + +func cueLineGap(source string, start, end int, allCues []model.Cue, agentID string) string { + start = max(start, 0) + end = min(end, len(source)) + if start >= end { + return "" + } + + excluded := make([]byteRange, 0, 1) + for _, cue := range allCues { + if strings.TrimSpace(cue.AgentID) == agentID { + continue + } + cueStart := max(cue.ByteStart, start) + cueEnd := min(cue.ByteEnd+1, end) + if cueStart < cueEnd { + excluded = append(excluded, byteRange{start: cueStart, end: cueEnd}) + } + } + + if len(excluded) == 0 { + return source[start:end] + } + + sort.SliceStable(excluded, func(i, j int) bool { + return excluded[i].start < excluded[j].start + }) + + var gap strings.Builder + cursor := start + for _, r := range excluded { + if r.start > cursor { + gap.WriteString(source[cursor:r.start]) + } + cursor = max(cursor, r.end) + } + if cursor < end { + gap.WriteString(source[cursor:end]) + } + return gap.String() +} + +func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { + if len(cues) == 0 { + return nil + } + + // Only resolve end times when at least one cue carries one; otherwise the + // group is start-only and must stay that way. + hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil }) + if hasAnyEnd { + cues = model.NormalizeCueEnds(cues, lineEnd) + } + + out := make([]responses.LyricCue, 0, len(cues)) + for i := range cues { + if cues[i].Start == nil { + continue + } + cue := responses.LyricCue{ + Start: *cues[i].Start, + Value: cues[i].Value, + ByteStart: cues[i].ByteStart, + ByteEnd: cues[i].ByteEnd, + } + if hasAnyEnd { + cue.End = cues[i].End + } + out = append(out, cue) + } + return out +} diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go new file mode 100644 index 000000000..8713b7a3b --- /dev/null +++ b/server/subsonic/lyrics_test.go @@ -0,0 +1,842 @@ +package subsonic + +import ( + "encoding/json" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetLyricsBySongId", func() { + var router *Router + var ds model.DataStore + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + DeferCleanup(configtest.SetupConfig()) + conf.Server.LyricsPriority = "embedded,.lrc" + }) + + const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" + const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" + const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" + var times = []int64{18800, 22801} + + compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { + Expect(actual).ToNot(BeNil()) + Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) + for i, realLyric := range actual.StructuredLyrics { + expectedLyric := expected.StructuredLyrics[i] + + Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) + Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) + Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) + Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) + + if expectedLyric.Offset == nil { + Expect(realLyric.Offset).To(BeNil()) + } else { + Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) + } + + Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) + for j, realLine := range realLyric.Line { + expectedLine := expectedLyric.Line[j] + Expect(realLine.Value).To(Equal(expectedLine.Value)) + + if expectedLine.Start == nil { + Expect(realLine.Start).To(BeNil()) + } else { + Expect(*realLine.Start).To(Equal(*expectedLine.Start)) + } + } + + Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) + for j, realCueLine := range realLyric.CueLine { + expectedCueLine := expectedLyric.CueLine[j] + Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) + Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) + Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) + if expectedCueLine.Start == nil { + Expect(realCueLine.Start).To(BeNil()) + } else { + Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) + } + if expectedCueLine.End == nil { + Expect(realCueLine.End).To(BeNil()) + } else { + Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) + } + + Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) + for k, realCue := range realCueLine.Cue { + expectedCue := expectedCueLine.Cue[k] + Expect(realCue.Value).To(Equal(expectedCue.Value)) + Expect(realCue.Start).To(Equal(expectedCue.Start)) + Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) + Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) + if expectedCue.End == nil { + Expect(realCue.End).To(BeNil()) + } else { + Expect(*realCue.End).To(Equal(*expectedCue.End)) + } + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(syncedLyrics)) + unsyncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "xxx", []byte(unsyncedLyrics)) + synced, _ := syncedList.Main() + unsynced, _ := unsyncedList.Main() + lyricsJson, err := json.Marshal(model.LyricList{ + synced, unsynced, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + Lang: "eng", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + }, + { + Lang: "xxx", + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Synced: false, + Line: []responses.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + }, + }, + }) + }) + + It("should parse lrc metadata", func() { + r := newGetRequest("id=1") + syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) + synced, _ := syncedList.Main() + lyricsJson, err := json.Marshal(model.LyricList{ + synced, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: ×[1], + Value: "You know the rules and so do I", + }, + }, + Offset: new(int64(-100)), + }, + }, + }) + }) + + It("should return multilingual TTML sidecar lyrics", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1") + + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + LibraryPath: fixturesDir, + Path: "test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + porTime := int64(18800) + ttmlTime := int64(22800) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: ×[0], + Value: "We're no strangers to love", + }, + { + Start: &ttmlTime, + Value: "You know the rules and so do I", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Lang: "por", + Synced: true, + Line: []responses.Line{ + { + Start: &porTime, + Value: "Nao somos estranhos ao amor", + }, + }, + }, + }, + }) + }) + + It("should return metadata-linked translation and pronunciation tracks from TTML", func() { + conf.Server.LyricsPriority = ".ttml,embedded" + r := newGetRequest("id=1&enhanced=true") + + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + LibraryPath: fixturesDir, + Path: "test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + + mainStartA := int64(1000) + mainStartB := int64(2000) + tokenStartA := int64(2000) + tokenEndA := int64(2300) + tokenStartB := int64(2300) + tokenEndB := int64(2600) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "ja", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "こんにちは", + }, + { + Start: &mainStartB, + Value: "こんばんは", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "translation", + Lang: "es", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartA, + Value: "Hola", + }, + }, + }, + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "pronunciation", + Lang: "ja-latn", + Synced: true, + Line: []responses.Line{ + { + Start: &mainStartB, + Value: "konni", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &mainStartB, + End: &tokenEndB, + Value: "konni", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 1, + Value: "ko", + }, + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 2, + ByteEnd: 4, + Value: "nni", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + tokenStartA := int64(1000) + tokenEndA := int64(1400) + tokenStartB := int64(2000) + tokenEndB := int64(2500) + lyricsJson, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + Cue: []model.Cue{ + { + Start: &tokenStartA, + End: &tokenEndA, + Value: "Hello", + ByteStart: 0, + ByteEnd: 4, + AgentID: "lead", + }, + { + Start: &tokenStartB, + End: &tokenEndB, + Value: "echo", + ByteStart: 6, + ByteEnd: 9, + AgentID: "__nd_bg__|lead", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Hello echo", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartA, + End: &tokenEndA, + ByteStart: 0, + ByteEnd: 4, + Value: "Hello", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "echo", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: tokenStartB, + End: &tokenEndB, + ByteStart: 0, + ByteEnd: 3, + Value: "echo", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should preserve shared edge text when remapping agent cue lines", func() { + lineStart := int64(1000) + lineEnd := int64(2000) + cueStart := int64(1200) + cueEnd := int64(1800) + + cueLines := buildCueLines(model.Line{ + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + Cue: []model.Cue{ + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "lead", + }, + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "__nd_bg__|lead", + }, + }, + }, 0, newLyricAgents([]model.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + })) + + Expect(cueLines).To(Equal([]responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + })) + }) + + It("should remap cue offsets for interleaved agent cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(82889) + lineEnd := int64(86859) + realStart := int64(85593) + realEnd := int64(85934) + slowStart := int64(85934) + slowEnd := int64(86751) + bgStartA := int64(83881) + bgEndA := int64(84243) + bgStartB := int64(86232) + bgEndB := int64(86859) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "real slow (When you slide)", + Cue: []model.Cue{ + { + Start: &realStart, + End: &realEnd, + Value: "real", + ByteStart: 0, + ByteEnd: 3, + AgentID: "v2", + }, + { + Start: &slowStart, + End: &slowEnd, + Value: "slow", + ByteStart: 5, + ByteEnd: 8, + AgentID: "v2", + }, + { + Start: &bgStartA, + End: &bgEndA, + Value: "(When you", + ByteStart: 10, + ByteEnd: 18, + AgentID: "__nd_bg__|v2", + }, + { + Start: &bgStartB, + End: &bgEndB, + Value: "slide)", + ByteStart: 20, + ByteEnd: 25, + AgentID: "__nd_bg__|v2", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "v2", Role: "main"}, + {ID: "__nd_bg__|v2", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "real slow (When you slide)", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "real slow", + AgentID: "v2", + Cue: []responses.LyricCue{ + { + Start: realStart, + End: &realEnd, + ByteStart: 0, + ByteEnd: 3, + Value: "real", + }, + { + Start: slowStart, + End: &slowEnd, + ByteStart: 5, + ByteEnd: 8, + Value: "slow", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(When you slide)", + AgentID: "__nd_bg__|v2", + Cue: []responses.LyricCue{ + { + Start: bgStartA, + End: &bgEndA, + ByteStart: 0, + ByteEnd: 8, + Value: "(When you", + }, + { + Start: bgStartB, + End: &bgEndB, + ByteStart: 10, + ByteEnd: 15, + Value: "slide)", + }, + }, + }, + }, + }, + }, + }) + }) + + It("should keep enhanced line-level lyrics when no cue data is available", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(1000) + lineEnd := int64(3000) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Kind: "main", + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "Line without word timing", + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "Line without word timing", + }, + }, + }, + }, + }) + }) + + It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + asciiLineStart := int64(0) + asciiLineEnd := int64(2400) + asciiCueStartA := int64(0) + asciiCueEndA := int64(300) + asciiCueStartB := int64(900) + asciiCueEndB := int64(1300) + asciiCueStartC := int64(1300) + asciiCueEndC := int64(1600) + asciiCueStartD := int64(1600) + + utfLineStart := int64(2747) + utfLineEnd := int64(6214) + utfCueStartA := int64(2747) + utfCueEndA := int64(3018) + utfCueStartB := int64(3018) + utfCueEndB := int64(3179) + utfCueStartC := int64(3582) + utfCueEndC := int64(4100) + utfCueStartD := int64(4500) + utfCueEndD := int64(6214) + + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Synced: true, + Line: []model.Line{ + { + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []model.Cue{ + {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []model.Cue{ + {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Line: []responses.Line{ + {Start: &asciiLineStart, Value: "Oh love love me tonight"}, + {Start: &utfLineStart, Value: "눈을 뜬 순간"}, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &asciiLineStart, + End: &asciiLineEnd, + Value: "Oh love love me tonight", + Cue: []responses.LyricCue{ + {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, + {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, + {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, + {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, + }, + }, + { + Index: 1, + Start: &utfLineStart, + End: &utfLineEnd, + Value: "눈을 뜬 순간", + Cue: []responses.LyricCue{ + {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, + {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, + {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, + {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, + }, + }, + }, + }, + }, + }) + }) +}) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index e8b0278c1..27170c11b 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "errors" "fmt" "math" "net/http" @@ -52,6 +53,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { case *model.Album: repo = api.ds.Album(ctx) resource = "album" + case *model.Playlist: + repo = api.ds.Playlist(ctx) + resource = "playlist" default: repo = api.ds.MediaFile(ctx) resource = "song" @@ -104,48 +108,50 @@ func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { - if len(ids) == 0 { - return nil - } - log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) if len(ids) == 0 { log.Warn(ctx, "Cannot star/unstar an empty list of ids") return nil } - event := &events.RefreshResource{} + log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + event := &events.RefreshResource{} + changed := false for _, id := range ids { - exist, err := tx.Album(ctx).Exists(id) + var repo model.AnnotatedRepository + var resource string + entity, err := model.GetEntityByID(ctx, tx, id) if err != nil { - return err - } - if exist { - err = tx.Album(ctx).SetStar(star, id) - if err != nil { + if !errors.Is(err, model.ErrNotFound) { return err } - event = event.With("album", id) + log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) continue } - exist, err = tx.Artist(ctx).Exists(id) - if err != nil { + switch entity.(type) { + case *model.Artist: + repo = tx.Artist(ctx) + resource = "artist" + case *model.Album: + repo = tx.Album(ctx) + resource = "album" + case *model.Playlist: + repo = tx.Playlist(ctx) + resource = "playlist" + default: + repo = tx.MediaFile(ctx) + resource = "song" + } + if err := repo.SetStar(star, id); err != nil { return err } - if exist { - err = tx.Artist(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("artist", id) - continue - } - err = tx.MediaFile(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("song", id) + event = event.With(resource, id) + changed = true + } + // Skip the broadcast when nothing changed: an empty RefreshResource + // serializes as a "{*:*}" wildcard, forcing every client to refresh. + if changed { + api.broker.SendMessage(ctx, event) } - api.broker.SendMessage(ctx, event) return nil }) if err != nil { diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 487335d1a..1b16dfc68 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -185,6 +185,64 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) }) }) + + Describe("Star/Unstar playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("stars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", true)) + }) + + It("unstars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Unstar(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", false)) + }) + }) + + Describe("SetRating playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("rates a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1", "rating=4") + + _, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4)) + }) + }) + + Describe("Star with an unresolvable id", func() { + It("skips the id without broadcasting an empty (wildcard) refresh", func() { + r := newGetRequest("id=does-not-exist") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.Events).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 9ab3a20b0..089a1fdda 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -98,22 +97,13 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { response := newResponse() lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse - mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) - + structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title) if err != nil { return nil, err } - if len(mediaFiles) == 0 { - return response, nil - } - - structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0]) - if err != nil { - return nil, err - } - - if len(structuredLyrics) == 0 { + mainLyric, ok := structuredLyrics.Main() + if !ok { return response, nil } @@ -121,10 +111,9 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Title = title var lyricsText strings.Builder - for _, line := range structuredLyrics[0].Line { + for _, line := range mainLyric.Line { lyricsText.WriteString(line.Value + "\n") } - lyricsResponse.Value = lyricsText.String() return response, nil @@ -146,8 +135,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } + enhanced, _ := req.Params(r).Bool("enhanced") + response := newResponse() - response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 27d1edb84..9331dfbe4 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net/http/httptest" + "path/filepath" "slices" "time" @@ -16,7 +17,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" @@ -78,7 +78,7 @@ var _ = Describe("MediaRetrievalController", func() { When("client disconnects (context is cancelled)", func() { It("should not call the service if cancelled before the call", func() { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(GinkgoT().Context()) r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) cancel() @@ -93,7 +93,7 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should not return data if cancelled during the call", func() { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(GinkgoT().Context()) defer cancel() r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) @@ -113,34 +113,19 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I") + lyricsList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) + lyrics, _ := lyricsList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *lyrics, + lyrics, }) Expect(err).ToNot(HaveOccurred()) - baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) mockRepo.SetData(model.MediaFiles{ { - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer - }, - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older - }, - { - ID: "3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), }, }) response, err := router.GetLyrics(r) @@ -149,6 +134,26 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) + It("should surface the main-kind track when translation tracks are present", func() { + r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + start := int64(0) + lyricsJSON, err := json.Marshal(model.LyricList{ + {Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}}, + {Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}}, + }) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + response, err := router.GetLyrics(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n")) + }) It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() { r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") mockRepo.SetData(model.MediaFiles{}) @@ -160,18 +165,15 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - Path: "tests/fixtures/test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - }, - { - Path: "tests/fixtures/test.mp3", - ID: "2", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", + LibraryPath: fixturesDir, + Path: "test.mp3", + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", }, }) response, err := router.GetLyrics(r) @@ -181,143 +183,6 @@ var _ = Describe("MediaRetrievalController", func() { Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) }) - - Describe("GetLyricsBySongId", func() { - const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" - const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" - var times = []int64{18800, 22801} - - compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { - Expect(actual).ToNot(BeNil()) - Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) - for i, realLyric := range actual.StructuredLyrics { - expectedLyric := expected.StructuredLyrics[i] - - Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) - Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) - Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) - - if expectedLyric.Offset == nil { - Expect(realLyric.Offset).To(BeNil()) - } else { - Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) - } - - Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) - for j, realLine := range realLyric.Line { - expectedLine := expectedLyric.Line[j] - Expect(realLine.Value).To(Equal(expectedLine.Value)) - - if expectedLine.Start == nil { - Expect(realLine.Start).To(BeNil()) - } else { - Expect(*realLine.Start).To(Equal(*expectedLine.Start)) - } - } - } - } - - It("should return mixed lyrics", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", syncedLyrics) - unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: false, - Line: []responses.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - }, - }) - }) - - It("should parse lrc metadata", func() { - r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) - lyricsJson, err := json.Marshal(model.LyricList{ - *synced, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - offset := int64(-100) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - Offset: &offset, - }, - }, - }) - }) - }) }) type fakeArtwork struct { diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 5832bb1de..837852d18 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -155,6 +155,23 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } } +func adminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loggedUser, ok := request.UserFrom(r.Context()) + if !ok { + sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) + return + } + + if !loggedUser.IsAdmin { + sendError(w, r, newError(responses.ErrorAuthorizationFail)) + return + } + + next.ServeHTTP(w, r) + }) +} + func validateCredentials(user *model.User, pass, token, salt, jwt string) error { valid := false diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index aba14a0aa..3f8c07a56 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -308,6 +308,36 @@ var _ = Describe("Middlewares", func() { }) }) + Describe("AdminOnly", func() { + It("passes admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + }) + + It("rejects non-admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="50"`)) + Expect(next.called).To(BeFalse()) + }) + + It("returns an internal error when user is missing from context", func() { + r := newGetRequest() + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="0"`)) + Expect(next.called).To(BeFalse()) + }) + }) + Describe("GetPlayer", func() { var mockedPlayers *mockPlayers var r *http.Request diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 85edb1012..97b3cafcc 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -11,7 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, - {Name: "songLyrics", Versions: []int32{1}}, + {Name: "songLyrics", Versions: []int32{1, 2}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 3ccbf232e..e4217303f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -58,7 +58,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(6), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), @@ -88,7 +88,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index a8c3da68c..7101f9f15 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -169,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 1d5f6a70a..697dd5852 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "time" "github.com/navidrome/navidrome/conf" @@ -248,6 +249,27 @@ var _ = Describe("buildPlaylist", func() { }) }) }) + + Describe("annotation leakage", func() { + It("does not serialize starred/rating even when the model carries them", func() { + p := model.Playlist{ID: "pl-1", Name: "My Playlist"} + p.Starred = true + p.Rating = 5 + + resp := router.buildPlaylist(ctx, p) + + data, err := json.Marshal(resp) + Expect(err).ToNot(HaveOccurred()) + var fields map[string]any + Expect(json.Unmarshal(data, &fields)).To(Succeed()) + Expect(fields).ToNot(HaveKey("starred")) + Expect(fields).ToNot(HaveKey("starredAt")) + Expect(fields).ToNot(HaveKey("rating")) + Expect(fields).ToNot(HaveKey("userRating")) + Expect(fields).ToNot(HaveKey("averageRating")) + Expect(fields).ToNot(HaveKey("playCount")) + }) + }) }) var _ = Describe("UpdatePlaylist", func() { diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index 8491a577b..d6b195f58 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -56,7 +56,12 @@ "displayAlbumArtist": "Display album artist", "contributors": [], "displayComposer": "", - "explicitStatus": "explicit" + "explicitStatus": "explicit", + "groupings": [ + "Soundtrack" + ], + "works": [], + "movements": [] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML index 5d9e83f96..d39fe2e7d 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML @@ -9,6 +9,7 @@ + Soundtrack diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 07678407a..f776c7535 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -8,7 +8,9 @@ "id": "1", "name": "album", "artist": "artist", + "songCount": 0, "duration": 292, + "created": "0001-01-01T00:00:00Z", "genre": "rock", "userRating": 4, "genres": [ @@ -165,7 +167,13 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ], + "works": [], + "movements": [] }, { "id": "2", @@ -210,7 +218,10 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [], + "works": [], + "movements": [] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index f7b23cb4e..16a7748aa 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -1,5 +1,5 @@ - + @@ -32,6 +32,8 @@ + Soundtrack + Live diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index 14e96939e..030502618 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -7,6 +7,8 @@ "album": { "id": "", "name": "", - "duration": 0 + "songCount": 0, + "duration": 0, + "created": "0001-01-01T00:00:00Z" } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 868265347..0d3882080 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON index 446368fa5..d3964663b 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -7,7 +7,9 @@ "album": { "id": "", "name": "", + "songCount": 0, "duration": 0, + "created": "0001-01-01T00:00:00Z", "userRating": 0, "genres": [], "musicBrainzId": "", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML index 868265347..0d3882080 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index d20a6d48c..fef60c9b1 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -110,7 +110,27 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ], + "works": [ + { + "name": "Symphony No. 5", + "musicBrainzId": "mbz-work-1" + }, + { + "name": "Encore" + } + ], + "movements": [ + { + "name": "I. Allegro", + "number": 1, + "count": 4 + } + ] }, { "id": "", @@ -141,7 +161,10 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [], + "works": [], + "movements": [] } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 1d307b0b9..b626fd6ea 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -24,6 +24,11 @@ + Soundtrack + Live + + + diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index 25284295e..ea23dc5d6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -28,7 +28,10 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [], + "works": [], + "movements": [] } ], "id": "", diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b0b2b8752..252eee4c6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -189,6 +189,9 @@ type OpenSubsonicChild struct { Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"` DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` + Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` + Works Array[Work] `xml:"works,omitempty" json:"works"` + Movements Array[Movement] `xml:"movements,omitempty" json:"movements"` } type Songs struct { @@ -250,10 +253,10 @@ type AlbumID3 struct { Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` Duration int32 `xml:"duration,attr" json:"duration"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + Created time.Time `xml:"created,attr" json:"created"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` @@ -546,13 +549,39 @@ type Line struct { Value string `xml:",chardata" json:"value"` } +type LyricCue struct { + Start int64 `xml:"start,attr" json:"start"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + ByteStart int `xml:"byteStart,attr" json:"byteStart"` + ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"` + Value string `xml:",chardata" json:"value"` +} + +type Agent struct { + ID string `xml:"id,attr" json:"id"` + Role string `xml:"role,attr" json:"role"` + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` +} + +type CueLine struct { + Index int32 `xml:"index,attr" json:"index"` + Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + Value string `xml:"value,attr" json:"value"` + AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` + Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"` +} + type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` - Synced bool `xml:"synced,attr" json:"synced"` + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` } type StructuredLyrics []StructuredLyric @@ -571,6 +600,17 @@ type ItemGenre struct { Name string `xml:"name,attr" json:"name"` } +type Work struct { + Name string `xml:"name,attr" json:"name"` + MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId,omitempty"` +} + +type Movement struct { + Name string `xml:"name,attr" json:"name"` + Number int32 `xml:"number,attr,omitempty" json:"number,omitempty"` + Count int32 `xml:"count,attr,omitempty" json:"count,omitempty"` +} + type ReplayGain struct { TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ee98a3daa..586e46b63 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -94,11 +93,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]Artist, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = Artist{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", } @@ -133,11 +131,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -158,11 +155,10 @@ var _ = Describe("Responses", func() { Context("with OpenSubsonic data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -211,12 +207,11 @@ var _ = Describe("Responses", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} child := make([]Child, 2) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, + Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), } child[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, @@ -224,7 +219,8 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1", "ISRC-2"}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + Groupings: []string{"Soundtrack", "Live"}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -243,9 +239,16 @@ var _ = Describe("Responses", func() { {Role: "composer", Artist: ArtistID3Ref{Id: "4", Name: "composer2"}}, }, ExplicitStatus: "clean", + Works: []Work{ + {Name: "Symphony No. 5", MusicBrainzId: "mbz-work-1"}, + {Name: "Encore"}, + }, + Movements: []Movement{ + {Name: "I. Allegro", Number: 1, Count: 4}, + }, } child[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.Directory.Child = child }) @@ -320,7 +323,8 @@ var _ = Describe("Responses", func() { Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + Groupings: []string{"Soundtrack", "Live"}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -340,7 +344,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } songs[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs @@ -424,6 +428,7 @@ var _ = Describe("Responses", func() { ItemGenre{Name: "Genre 2"}, }, Moods: []string{"mood1", "mood2"}, + Groupings: []string{"Soundtrack"}, DisplayArtist: "Display artist", Artists: Array[ArtistID3Ref]{ ArtistID3Ref{Id: "artist-1", Name: "Artist 1"}, @@ -801,7 +806,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.PlayQueueByIndex.Username = "user1" - response.PlayQueueByIndex.CurrentIndex = gg.P(0) + response.PlayQueueByIndex.CurrentIndex = new(0) response.PlayQueueByIndex.Position = 243 response.PlayQueueByIndex.Changed = time.Time{} response.PlayQueueByIndex.ChangedBy = "a_client" diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index fd7e29587..5d4989ae5 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -74,7 +74,7 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI if len(musicFolderIds) > 0 { songOpts.Filters = Eq{"library_id": musicFolderIds} albumOpts.Filters = Eq{"library_id": musicFolderIds} - artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds} + artistOpts.Filters = Eq{"library_id": musicFolderIds} } // Run searches in parallel diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 9a9c6af6f..4e72bd2e6 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -39,12 +39,17 @@ var _ = Describe("Search", func() { } Describe("Search2", func() { - It("should accept musicFolderId parameter", func() { + It("scopes all entity types to the requested libraries", func() { + // The subsonic layer passes the same library_id filter to all three repos; the + // artist repository translates it to the join-free library_artist predicate itself. r := newGetRequest("query=test", "musicFolderId=1") ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, }) r = r.WithContext(ctx) @@ -54,14 +59,13 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult2).ToNot(BeNil()) - // Verify that library filter was applied to all repositories assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) }) - It("should return results from all accessible libraries when musicFolderId is not provided", func() { - r := newGetRequest("query=test") + It("applies no library filter when musicFolderId is not provided", func() { + r := newGetRequest("query=test") // no musicFolderId → all accessible libraries ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -79,10 +83,9 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult2).ToNot(BeNil()) - // Verify that library filter was applied to all repositories with all accessible libraries assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) }) It("should return empty results when user has no accessible libraries", func() { @@ -122,12 +125,15 @@ var _ = Describe("Search", func() { }) Describe("Search3", func() { - It("should accept musicFolderId parameter", func() { + It("scopes all entity types to the requested libraries", func() { r := newGetRequest("query=test", "musicFolderId=1") ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{ + {ID: 1, Name: "Library 1"}, + {ID: 2, Name: "Library 2"}, + }, }) r = r.WithContext(ctx) @@ -137,14 +143,13 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify that library filter was applied to all repositories assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) }) - It("should return results from all accessible libraries when musicFolderId is not provided", func() { - r := newGetRequest("query=test") + It("applies no library filter when musicFolderId is not provided", func() { + r := newGetRequest("query=test") // no musicFolderId → all accessible libraries ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -162,10 +167,9 @@ var _ = Describe("Search", func() { Expect(resp).ToNot(BeNil()) Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify that library filter was applied to all repositories with all accessible libraries assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) }) It("should return empty results when user has no accessible libraries", func() { diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 9cc8d7097..a9ccfdca4 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -58,12 +58,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), ResourceIDs: strings.Join(ids, ","), } @@ -90,13 +88,11 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ ID: id, Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index b49af2b24..28b4585f0 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,12 +1,15 @@ package subsonic import ( + "context" + "errors" "fmt" "net/http" "strconv" "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -119,14 +122,28 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err case *model.Album: setHeaders(v.Name) - return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)) case *model.Artist: setHeaders(v.Name) - return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)) case *model.Playlist: setHeaders(v.Name) - return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)) default: return nil, model.ErrNotFound } } + +// handleArchiveErr swallows ErrTooManyTranscodes from archive downloads so the +// outer error handler does not try to write a 429 onto a response whose status +// and Content-Disposition have already been flushed. The archive ends up with +// the tracks that were written before the rejection (the rejected track and +// any following ones are omitted); the server-side log is the unambiguous +// signal operators can act on. +func handleArchiveErr(ctx context.Context, id string, err error) error { + if errors.Is(err, stream.ErrTooManyTranscodes) { + log.Warn(ctx, "Archive download finalized early: transcode cap reached", "id", id, err) + return nil + } + return err +} diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 4e494b324..511db2b85 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" ) @@ -278,6 +279,28 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + // Honor the player's forced transcoding format, falling back to normal + // negotiation when the client can't play it (issue #5583). + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + if !clientInfo.ForceFormat(trc.TargetFormat) { + clientName := clientInfo.Name + if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" { + clientName = player.Client + } + log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", + "forcedFormat", trc.TargetFormat, "client", clientName) + } + } + + // Apply the player's MaxBitRate as a ceiling on the client's declared + // limits (issue #5583). Both fields are capped because the client sends + // them independently here; capping only MaxAudioBitrate would let an + // independent MaxTranscodingAudioBitrate slip through computeBitrate. + if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) { + log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision", + "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + // Get media file mf, err := api.ds.MediaFile(ctx).Get(mediaID) if err != nil { @@ -370,6 +393,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* if err != nil { switch { case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale): + log.Warn(ctx, "Invalid or stale transcode token", "mediaID", mediaID, err) http.Error(w, "Gone", http.StatusGone) default: log.Error(ctx, "Error validating transcode params", err) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index adc7b7600..7e36ab243 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -205,7 +206,7 @@ var _ = Describe("Transcode endpoints", func() { It("includes transcode stream when transcoding", func() { mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}, }) mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-2", @@ -234,6 +235,143 @@ var _ = Describe("Transcode endpoints", func() { Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) }) + + Describe("player MaxBitRate cap", func() { + withPlayer := func(r *http.Request, maxBitRate int) *http.Request { + ctx := request.WithPlayer(r.Context(), model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true} + mockTD.token = "token" + }) + + It("caps client MaxAudioBitrate at the player MaxBitRate when client declares none", func() { + body := `{"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient).ToNot(BeNil()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(320)) + }) + + It("does not raise a lower client-declared limit", func() { + // Client declares 192 kbps (192000 bps); player cap is 320 — client wins. + body := `{"maxAudioBitrate":192000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + }) + + It("lowers a higher client-declared limit to the player cap", func() { + // Client declares 320 kbps (320000 bps); player cap is 192 — player wins. + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 192) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("does nothing when no player is in context", func() { + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + + It("does nothing when player MaxBitRate is 0", func() { + body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` + r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + }) + + Describe("player forced format", func() { + withForcedFormat := func(r *http.Request, format string, maxBitRate int) *http.Request { + ctx := r.Context() + ctx = request.WithTranscoding(ctx, model.Transcoding{TargetFormat: format}) + if maxBitRate > 0 { + ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) + } + return r.WithContext(ctx) + } + + BeforeEach(func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanTranscode: true} + mockTD.token = "token" + }) + + It("forces a supported format and clears direct play", func() { + body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], + "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty()) + }) + + It("falls back to negotiation when the forced format is unsupported", func() { + // Forced format is opus, but the client only declares mp3 and flac. + // Should fall back to negotiating among the client's own profiles. + body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], + "transcodingProfiles":[ + {"container":"flac","audioCodec":"flac","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + // Profiles left intact for normal negotiation (forced format not applied). + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(2)) + Expect(mockTD.capturedClient.DirectPlayProfiles).ToNot(BeEmpty()) + }) + + It("applies the maxBitRate cap on top of the forced format", func() { + // Client supports opus + mp3; forced format opus must be selected, + // and the maxBitRate cap applied on top. + body := `{"transcodingProfiles":[ + {"container":"ogg","audioCodec":"opus","protocol":"http"}, + {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 128) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) + Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + }) }) Describe("GetTranscodeStream", func() { diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 8b7406b60..acf8de3e8 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -46,8 +46,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorAuthorizationFail) } response := newResponse() - user := buildUserResponse(loggedUser) - response.User = &user + response.User = new(buildUserResponse(loggedUser)) return response, nil } diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go index c3672fd1e..0d31d289e 100644 --- a/server/throttle_backlog.go +++ b/server/throttle_backlog.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "maps" "net/http" "sync" "time" @@ -76,9 +77,7 @@ func (t *requestThrottle) handler(next http.Handler) http.Handler { next.ServeHTTP(buf, r) }() - for k, v := range buf.header { - w.Header()[k] = v - } + maps.Copy(w.Header(), buf.header) if buf.code > 0 { w.WriteHeader(buf.code) } diff --git a/tests/fixtures/bom-test.ttml b/tests/fixtures/bom-test.ttml new file mode 100644 index 000000000..319ab1f07 --- /dev/null +++ b/tests/fixtures/bom-test.ttml @@ -0,0 +1,2 @@ + +

BOM test line

diff --git a/tests/fixtures/bom-utf16-test.ttml b/tests/fixtures/bom-utf16-test.ttml new file mode 100644 index 000000000..a5621ef5d Binary files /dev/null and b/tests/fixtures/bom-utf16-test.ttml differ diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc new file mode 100644 index 000000000..41342d910 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.elrc @@ -0,0 +1,27 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[lang:eng] +[00:00.00]<00:00.00>Should <00:00.90>auld <00:01.80>acquaintance <00:02.70>be <00:03.60>forgot, +[00:04.50]<00:04.50>And <00:05.40>never <00:06.30>brought <00:07.20>to <00:08.10>mind? +[00:09.00]<00:09.00>Should <00:09.90>auld <00:10.80>acquaintance <00:11.70>be <00:12.60>forgot, +[00:13.50]<00:13.50>And <00:14.62>auld <00:15.75>lang <00:16.88>syne? +[00:18.00]<00:18.00>For <00:18.75>auld <00:19.50>lang <00:20.25>syne, <00:21.00>my <00:21.75>dear, +[00:22.50]<00:22.50>For <00:23.62>auld <00:24.75>lang <00:25.88>syne, +[00:27.00]<00:27.00>We'll <00:27.64>tak <00:28.29>a <00:28.93>cup <00:29.57>o' <00:30.21>kindness <00:30.86>yet, +[00:31.50]<00:31.50>For <00:32.62>auld <00:33.75>lang <00:34.88>syne. +[00:36.00]<00:36.00>And <00:36.75>surely <00:37.50>ye'll <00:38.25>be <00:39.00>your <00:39.75>pint-stowp, +[00:40.50]<00:40.50>And <00:41.40>surely <00:42.30>I'll <00:43.20>be <00:44.10>mine, +[00:45.00]<00:45.00>And <00:45.56>we'll <00:46.12>tak <00:46.69>a <00:47.25>cup <00:47.81>o' <00:48.38>kindness <00:48.94>yet, +[00:49.50]<00:49.50>For <00:50.62>auld <00:51.75>lang <00:52.88>syne. +[00:54.00]<00:54.00>We <00:54.64>twa <00:55.29>hae <00:55.93>run <00:56.57>about <00:57.21>the <00:57.86>braes, +[00:58.50]<00:58.50>And <00:59.40>pou'd <01:00.30>the <01:01.20>gowans <01:02.10>fine, +[01:03.00]<01:03.00>But <01:03.64>we've <01:04.29>wander'd <01:04.93>mony <01:05.57>a <01:06.21>weary <01:06.86>fit, +[01:07.50]<01:07.50>Sin <01:08.62>auld <01:09.75>lang <01:10.88>syne. +[01:12.00]<01:12.00>We <01:12.64>twa <01:13.29>hae <01:13.93>paidl'd <01:14.57>in <01:15.21>the <01:15.86>burn, +[01:16.50]<01:16.50>Frae <01:17.40>morning <01:18.30>sun <01:19.20>till <01:20.10>dine, +[01:21.00]<01:21.00>But <01:21.64>seas <01:22.29>between <01:22.93>us <01:23.57>braid <01:24.21>hae <01:24.86>roar'd +[01:25.50]<01:25.50>Sin <01:26.62>auld <01:27.75>lang <01:28.88>syne. +[01:30.00]<01:30.00>And <01:30.64>there's <01:31.29>a <01:31.93>hand, <01:32.57>my <01:33.21>trusty <01:33.86>fiere, +[01:34.50]<01:34.50>And <01:35.25>gie's <01:36.00>a <01:36.75>hand <01:37.50>o' <01:38.25>thine, +[01:39.00]<01:39.00>And <01:39.64>we'll <01:40.29>tak <01:40.93>a <01:41.57>right <01:42.21>gude-willie <01:42.86>waught, +[01:43.50]<01:43.50>For <01:44.62>auld <01:45.75>lang <01:46.88>syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.lrc b/tests/fixtures/lyrics/auld-lang-syne.lrc new file mode 100644 index 000000000..56021870a --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.lrc @@ -0,0 +1,28 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[al:Traditional] +[lang:eng] +[00:00.00]Should auld acquaintance be forgot, +[00:04.50]And never brought to mind? +[00:09.00]Should auld acquaintance be forgot, +[00:13.50]And auld lang syne? +[00:18.00]For auld lang syne, my dear, +[00:22.50]For auld lang syne, +[00:27.00]We'll tak a cup o' kindness yet, +[00:31.50]For auld lang syne. +[00:36.00]And surely ye'll be your pint-stowp, +[00:40.50]And surely I'll be mine, +[00:45.00]And we'll tak a cup o' kindness yet, +[00:49.50]For auld lang syne. +[00:54.00]We twa hae run about the braes, +[00:58.50]And pou'd the gowans fine, +[01:03.00]But we've wander'd mony a weary fit, +[01:07.50]Sin auld lang syne. +[01:12.00]We twa hae paidl'd in the burn, +[01:16.50]Frae morning sun till dine, +[01:21.00]But seas between us braid hae roar'd +[01:25.50]Sin auld lang syne. +[01:30.00]And there's a hand, my trusty fiere, +[01:34.50]And gie's a hand o' thine, +[01:39.00]And we'll tak a right gude-willie waught, +[01:43.50]For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.srt b/tests/fixtures/lyrics/auld-lang-syne.srt new file mode 100644 index 000000000..116bec0bf --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.srt @@ -0,0 +1,95 @@ +1 +00:00:00,000 --> 00:00:04,500 +Should auld acquaintance be forgot, + +2 +00:00:04,500 --> 00:00:09,000 +And never brought to mind? + +3 +00:00:09,000 --> 00:00:13,500 +Should auld acquaintance be forgot, + +4 +00:00:13,500 --> 00:00:18,000 +And auld lang syne? + +5 +00:00:18,000 --> 00:00:22,500 +For auld lang syne, my dear, + +6 +00:00:22,500 --> 00:00:27,000 +For auld lang syne, + +7 +00:00:27,000 --> 00:00:31,500 +We'll tak a cup o' kindness yet, + +8 +00:00:31,500 --> 00:00:36,000 +For auld lang syne. + +9 +00:00:36,000 --> 00:00:40,500 +And surely ye'll be your pint-stowp, + +10 +00:00:40,500 --> 00:00:45,000 +And surely I'll be mine, + +11 +00:00:45,000 --> 00:00:49,500 +And we'll tak a cup o' kindness yet, + +12 +00:00:49,500 --> 00:00:54,000 +For auld lang syne. + +13 +00:00:54,000 --> 00:00:58,500 +We twa hae run about the braes, + +14 +00:00:58,500 --> 00:01:03,000 +And pou'd the gowans fine, + +15 +00:01:03,000 --> 00:01:07,500 +But we've wander'd mony a weary fit, + +16 +00:01:07,500 --> 00:01:12,000 +Sin auld lang syne. + +17 +00:01:12,000 --> 00:01:16,500 +We twa hae paidl'd in the burn, + +18 +00:01:16,500 --> 00:01:21,000 +Frae morning sun till dine, + +19 +00:01:21,000 --> 00:01:25,500 +But seas between us braid hae roar'd + +20 +00:01:25,500 --> 00:01:30,000 +Sin auld lang syne. + +21 +00:01:30,000 --> 00:01:34,500 +And there's a hand, my trusty fiere, + +22 +00:01:34,500 --> 00:01:39,000 +And gie's a hand o' thine, + +23 +00:01:39,000 --> 00:01:43,500 +And we'll tak a right gude-willie waught, + +24 +00:01:43,500 --> 00:01:48,000 +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.ttml b/tests/fixtures/lyrics/auld-lang-syne.ttml new file mode 100644 index 000000000..a08be29e2 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.ttml @@ -0,0 +1,31 @@ + + + +
+

Should auld acquaintance be forgot,

+

And never brought to mind?

+

Should auld acquaintance be forgot,

+

And auld lang syne?

+

For auld lang syne, my dear,

+

For auld lang syne,

+

We'll tak a cup o' kindness yet,

+

For auld lang syne.

+

And surely ye'll be your pint-stowp,

+

And surely I'll be mine,

+

And we'll tak a cup o' kindness yet,

+

For auld lang syne.

+

We twa hae run about the braes,

+

And pou'd the gowans fine,

+

But we've wander'd mony a weary fit,

+

Sin auld lang syne.

+

We twa hae paidl'd in the burn,

+

Frae morning sun till dine,

+

But seas between us braid hae roar'd

+

Sin auld lang syne.

+

And there's a hand, my trusty fiere,

+

And gie's a hand o' thine,

+

And we'll tak a right gude-willie waught,

+

For auld lang syne.

+
+ +
diff --git a/tests/fixtures/lyrics/auld-lang-syne.txt b/tests/fixtures/lyrics/auld-lang-syne.txt new file mode 100644 index 000000000..42ab8330e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.txt @@ -0,0 +1,24 @@ +Should auld acquaintance be forgot, +And never brought to mind? +Should auld acquaintance be forgot, +And auld lang syne? +For auld lang syne, my dear, +For auld lang syne, +We'll tak a cup o' kindness yet, +For auld lang syne. +And surely ye'll be your pint-stowp, +And surely I'll be mine, +And we'll tak a cup o' kindness yet, +For auld lang syne. +We twa hae run about the braes, +And pou'd the gowans fine, +But we've wander'd mony a weary fit, +Sin auld lang syne. +We twa hae paidl'd in the burn, +Frae morning sun till dine, +But seas between us braid hae roar'd +Sin auld lang syne. +And there's a hand, my trusty fiere, +And gie's a hand o' thine, +And we'll tak a right gude-willie waught, +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.yaml b/tests/fixtures/lyrics/auld-lang-syne.yaml new file mode 100644 index 000000000..ca2a3d32e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.yaml @@ -0,0 +1,95 @@ +version: '1.0' +metadata: + title: 'Auld Lang Syne' + artist: 'Robert Burns' + album: 'Traditional' + language: 'eng' +lines: + - text: "Should auld acquaintance be forgot," + start_ms: 0 + end_ms: 4500 + words: + - text: "Should " + start_ms: 0 + end_ms: 900 + - text: "auld " + start_ms: 900 + end_ms: 1800 + - text: "acquaintance " + start_ms: 1800 + end_ms: 2700 + - text: "be " + start_ms: 2700 + end_ms: 3600 + - text: "forgot," + start_ms: 3600 + end_ms: 4500 + - text: "And never brought to mind?" + start_ms: 4500 + end_ms: 9000 + - text: "Should auld acquaintance be forgot," + start_ms: 9000 + end_ms: 13500 + - text: "And auld lang syne?" + start_ms: 13500 + end_ms: 18000 + - text: "For auld lang syne, my dear," + start_ms: 18000 + end_ms: 22500 + - text: "For auld lang syne," + start_ms: 22500 + end_ms: 27000 + - text: "We'll tak a cup o' kindness yet," + start_ms: 27000 + end_ms: 31500 + - text: "For auld lang syne." + start_ms: 31500 + end_ms: 36000 + - text: "And surely ye'll be your pint-stowp," + start_ms: 36000 + end_ms: 40500 + - text: "And surely I'll be mine," + start_ms: 40500 + end_ms: 45000 + - text: "And we'll tak a cup o' kindness yet," + start_ms: 45000 + end_ms: 49500 + - text: "For auld lang syne." + start_ms: 49500 + end_ms: 54000 + - text: "We twa hae run about the braes," + start_ms: 54000 + end_ms: 58500 + - text: "And pou'd the gowans fine," + start_ms: 58500 + end_ms: 63000 + - text: "But we've wander'd mony a weary fit," + start_ms: 63000 + end_ms: 67500 + - text: "Sin auld lang syne." + start_ms: 67500 + end_ms: 72000 + - text: "We twa hae paidl'd in the burn," + start_ms: 72000 + end_ms: 76500 + - text: "Frae morning sun till dine," + start_ms: 76500 + end_ms: 81000 + - text: "But seas between us braid hae roar'd" + start_ms: 81000 + end_ms: 85500 + - text: "Sin auld lang syne." + start_ms: 85500 + end_ms: 90000 + - text: "And there's a hand, my trusty fiere," + start_ms: 90000 + end_ms: 94500 + - text: "And gie's a hand o' thine," + start_ms: 94500 + end_ms: 99000 + - text: "And we'll tak a right gude-willie waught," + start_ms: 99000 + end_ms: 103500 + - text: "For auld lang syne." + start_ms: 103500 + end_ms: 108000 diff --git a/tests/fixtures/symlink_chain/evil1.mp3 b/tests/fixtures/symlink_chain/evil1.mp3 new file mode 120000 index 000000000..79c5d6f02 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil1.mp3 @@ -0,0 +1 @@ +../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 new file mode 120000 index 000000000..56d18ad24 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil2.mp3 @@ -0,0 +1 @@ +evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 new file mode 120000 index 000000000..e1cac02e9 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil3.mp3 @@ -0,0 +1 @@ +evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 new file mode 120000 index 000000000..887033521 --- /dev/null +++ b/tests/fixtures/symlink_chain/level1.mp3 @@ -0,0 +1 @@ +../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 new file mode 120000 index 000000000..eca2115ee --- /dev/null +++ b/tests/fixtures/symlink_chain/level2.mp3 @@ -0,0 +1 @@ +level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 new file mode 120000 index 000000000..dd72f3cca --- /dev/null +++ b/tests/fixtures/symlink_chain/level3.mp3 @@ -0,0 +1 @@ +level2.mp3 \ No newline at end of file diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc new file mode 100644 index 000000000..8f7b60f8c --- /dev/null +++ b/tests/fixtures/test-enhanced.lrc @@ -0,0 +1,6 @@ +[ar:Test Artist] +[ti:Enhanced Test] +[lang:eng] +[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here +[00:03.00]<00:03.00>More <00:03.50>words +[00:05.00]Plain line without inline markers diff --git a/tests/fixtures/test-instrumental.yaml b/tests/fixtures/test-instrumental.yaml new file mode 100644 index 000000000..84190a3b0 --- /dev/null +++ b/tests/fixtures/test-instrumental.yaml @@ -0,0 +1,6 @@ +version: '1.0' +metadata: + title: 'Solo Piano' + artist: 'Composer' + language: 'eng' + instrumental: true diff --git a/tests/fixtures/test-metadata.ttml b/tests/fixtures/test-metadata.ttml new file mode 100644 index 000000000..c0243c18f --- /dev/null +++ b/tests/fixtures/test-metadata.ttml @@ -0,0 +1,25 @@ + + + + + + + + Hola + + + + + konni + + + + + + +
+

こんにちは

+

こんばんは

+
+ +
diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml new file mode 100644 index 000000000..c1f95a87b --- /dev/null +++ b/tests/fixtures/test-overlapping.yaml @@ -0,0 +1,24 @@ +version: '1.0' +metadata: + title: 'Duet' + artist: 'Lead and Echo' + language: 'eng' + +lines: + - text: "Lead vocal" + start_ms: 1000 + end_ms: 4000 + words: + - text: "Lead " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 2000 + end_ms: 3000 + words: + - text: "echo" + start_ms: 2000 + end_ms: 3000 diff --git a/tests/fixtures/test-words.yaml b/tests/fixtures/test-words.yaml new file mode 100644 index 000000000..625098d6a --- /dev/null +++ b/tests/fixtures/test-words.yaml @@ -0,0 +1,17 @@ +version: '1.0' +metadata: + title: 'Karaoke Test' + artist: 'Test Artist' + language: 'eng' + +lines: + - text: "Hello world" + start_ms: 1000 + end_ms: 3000 + words: + - text: "Hello " + start_ms: 1000 + end_ms: 1500 + - text: "world" + start_ms: 1500 + end_ms: 3000 diff --git a/tests/fixtures/test.elrc b/tests/fixtures/test.elrc new file mode 100644 index 000000000..01c3d2cdd --- /dev/null +++ b/tests/fixtures/test.elrc @@ -0,0 +1,5 @@ +[ar:ELRC Artist] +[ti:ELRC Song] +[lang:eng] +[00:01.00]<00:01.00>Lead <00:01.50>words +[00:03.00]Fallback line diff --git a/tests/fixtures/test.srt b/tests/fixtures/test.srt new file mode 100644 index 000000000..3c9c09a39 --- /dev/null +++ b/tests/fixtures/test.srt @@ -0,0 +1,7 @@ +1 +00:00:18,800 --> 00:00:22,800 +We're from subtitles + +2 +00:00:22,801 --> 00:00:26,000 +Another subtitle line diff --git a/tests/fixtures/test.ttml b/tests/fixtures/test.ttml new file mode 100644 index 000000000..a85673a1b --- /dev/null +++ b/tests/fixtures/test.ttml @@ -0,0 +1,12 @@ + + + +
+

We're no strangers to love

+

You know the rules and so do I

+
+
+

Nao somos estranhos ao amor

+
+ +
diff --git a/tests/fixtures/test.yaml b/tests/fixtures/test.yaml new file mode 100644 index 000000000..bc5022b75 --- /dev/null +++ b/tests/fixtures/test.yaml @@ -0,0 +1,12 @@ +version: '1.0' +metadata: + title: 'Sample Track' + artist: 'Test Artist' + language: 'eng' + offset_ms: -100 + +lines: + - text: "We're no strangers to love" + start_ms: 18800 + - text: "You know the rules and so do I" + start_ms: 22801 diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 01eacae30..f15ba1bc6 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -98,6 +98,17 @@ func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, return result, nil } +func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFiles, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + if len(qo) > 0 && qo[0].Max > 0 && len(res) > qo[0].Max { + res = res[:qo[0].Max] + } + return res, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9b38ea5b5..b7df5361f 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -2,6 +2,7 @@ package tests import ( "errors" + "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -19,8 +20,11 @@ type MockPlaylistRepo struct { model.PlaylistRepository Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path + All model.Playlists Last *model.Playlist Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating Err bool TracksRepo model.PlaylistTrackRepository } @@ -29,6 +33,14 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } +func (m *MockPlaylistRepo) SetData(pls model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(pls)) + m.All = pls + for i, p := range m.All { + m.Data[p.ID] = &m.All[i] + } +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -45,6 +57,13 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } +func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") @@ -79,6 +98,44 @@ func (m *MockPlaylistRepo) Delete(id string) error { return nil } +func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { + if m.Err { + return errors.New("error") + } + if m.Starred == nil { + m.Starred = map[string]bool{} + } + for _, id := range ids { + m.Starred[id] = starred + } + return nil +} + +func (m *MockPlaylistRepo) SetRating(rating int, id string) error { + if m.Err { + return errors.New("error") + } + if m.Ratings == nil { + m.Ratings = map[string]int{} + } + m.Ratings[id] = rating + return nil +} + +func (m *MockPlaylistRepo) IncPlayCount(string, time.Time) error { + if m.Err { + return errors.New("error") + } + return nil +} + +func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { + if m.Err { + return errors.New("error") + } + return nil +} + func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { return m.TracksRepo } diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 5865f423a..2eb5e8a93 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -83,6 +83,22 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { return nil } +func (m *MockedScrobbleBufferRepo) Discard(service string) error { + if m.Error != nil { + return m.Error + } + m.mu.Lock() + defer m.mu.Unlock() + newData := model.ScrobbleEntries{} + for _, e := range m.Data { + if e.Service != service { + newData = append(newData, e) + } + } + m.Data = newData + return nil +} + func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 34561c257..d6d88d221 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -2,6 +2,7 @@ package tests import ( "context" + "strconv" "time" "github.com/navidrome/navidrome/model" @@ -13,12 +14,32 @@ type MockScrobbleRepo struct { ctx context.Context } +func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { + for idx := range m.RecordedScrobbles { + if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { + return &m.RecordedScrobbles[idx], nil + } + } + + return nil, model.ErrNotFound +} + +func (m *MockScrobbleRepo) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + return m.RecordedScrobbles, nil +} + +func (m *MockScrobbleRepo) CountAll(options ...model.QueryOptions) (int64, error) { + return int64(len(m.RecordedScrobbles)), nil +} + func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { user, _ := request.UserFrom(m.ctx) m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ MediaFileID: fileID, UserID: user.ID, - SubmissionTime: submissionTime, + SubmissionTime: submissionTime.Unix(), }) return nil } + +var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 796e84111..641daca8a 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -19,9 +19,9 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil case "flac": - return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil case "aac": - return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index cc05829f6..2d6ff3c02 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -7,7 +7,6 @@ import ( "time" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" ) func CreateMockUserRepo() *MockedUserRepo { @@ -58,6 +57,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use return u.FindByUsername(username) } +func (u *MockedUserRepo) FindFirstAdmin() (*model.User, error) { + if u.Error != nil { + return nil, u.Error + } + for _, usr := range u.Data { + if usr.IsAdmin { + return usr, nil + } + } + return nil, model.ErrNotFound +} + func (u *MockedUserRepo) Get(id string) (*model.User, error) { if u.Error != nil { return nil, u.Error @@ -84,7 +95,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastLoginAt = gg.P(time.Now()) + usr.LastLoginAt = new(time.Now()) return nil } } @@ -94,7 +105,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastAccessAt = gg.P(time.Now()) + usr.LastAccessAt = new(time.Now()) return nil } } diff --git a/ui/embed.go b/ui/embed.go index 3e2c413b3..2d5fcd979 100644 --- a/ui/embed.go +++ b/ui/embed.go @@ -5,7 +5,7 @@ import ( "io/fs" ) -//go:embed build/* +//go:embed all:build var filesystem embed.FS func BuildAssets() fs.FS { diff --git a/ui/package-lock.json b/ui/package-lock.json index 1f95f14f8..0feab6e7d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.2", + "navidrome-music-player": "4.25.4", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", @@ -129,6 +129,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1743,6 +1744,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1766,6 +1768,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2471,6 +2474,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.3", "ajv": "^6.10.2", @@ -2524,6 +2528,7 @@ "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash": "^4.17.15", "object-hash": "^2.0.0" @@ -2539,6 +2544,7 @@ "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2585,6 +2591,7 @@ "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -3308,6 +3315,7 @@ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", + "peer": true, "dependencies": { "hoist-non-react-statics": "^3.3.0" }, @@ -3360,6 +3368,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3381,6 +3390,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3550,6 +3560,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3883,6 +3894,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4567,6 +4579,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4942,6 +4955,7 @@ "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", + "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5822,6 +5836,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6405,6 +6420,7 @@ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -6421,6 +6437,7 @@ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", + "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6835,6 +6852,7 @@ "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6948,6 +6966,7 @@ "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -8550,6 +8569,7 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -8593,9 +8613,9 @@ "license": "MIT" }, "node_modules/navidrome-music-player": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.2.tgz", - "integrity": "sha512-k7RXHOOKHeJRCsfmpmQ+TkErndckFfvYMjzwVAKZvViw2PL9ubKWziPfruHZVQr4FiJd2oYKEuTNiWZgAK87CA==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz", + "integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==", "license": "MIT", "dependencies": { "@react-icons/all-files": "^4.1.0", @@ -9298,6 +9318,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9389,6 +9410,7 @@ "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", + "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9803,6 +9825,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9884,6 +9907,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9956,6 +9980,7 @@ "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -9973,6 +9998,7 @@ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -10078,6 +10104,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -10113,6 +10140,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10133,6 +10161,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -10314,6 +10343,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -10323,6 +10353,7 @@ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", + "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -10582,6 +10613,7 @@ "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11511,6 +11543,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11732,6 +11765,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11976,6 +12010,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12100,6 +12135,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12113,6 +12149,7 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", @@ -12632,6 +12669,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12741,6 +12779,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/ui/package.json b/ui/package.json index b440f0595..b02012104 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,7 +32,7 @@ "inflection": "^3.0.2", "jwt-decode": "^4.0.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.2", + "navidrome-music-player": "4.25.4", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", diff --git a/ui/public/fonts/Unbounded-Variable.woff2 b/ui/public/fonts/Unbounded-Variable.woff2 index 96d8ff5fa..a2f74491f 100644 Binary files a/ui/public/fonts/Unbounded-Variable.woff2 and b/ui/public/fonts/Unbounded-Variable.woff2 differ diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 35eaee3eb..d10aa5a33 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,7 +1,12 @@ import ReactGA from 'react-ga' import { Provider } from 'react-redux' import { createHashHistory } from 'history' -import { Admin as RAAdmin, Resource } from 'react-admin' +import { + Admin as RAAdmin, + Resource, + useSetLocale, + useRefresh, +} from 'react-admin' import { HotKeys } from 'react-hotkeys' import dataProvider from './dataProvider' import authProvider from './authProvider' @@ -36,7 +41,7 @@ import { transcodingReducer, } from './reducers' import createAdminStore from './store/createAdminStore' -import { i18nProvider } from './i18n' +import { i18nProvider, retrieveTranslation } from './i18n' import config, { shareInfo } from './config' import { keyMap } from './hotkeys' import useChangeThemeColor from './useChangeThemeColor' @@ -44,6 +49,7 @@ import SharePlayer from './share/SharePlayer' import { HTML5Backend } from 'react-dnd-html5-backend' import { DndProvider } from 'react-dnd' import missing from './missing/index.js' +import { useEffect } from 'react' const history = createHashHistory() @@ -84,6 +90,24 @@ const App = () => ( ) const Admin = (props) => { + const setLocale = useSetLocale() + const refresh = useRefresh() + useEffect(() => { + if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { + retrieveTranslation(config.defaultLanguage) + .then(() => setLocale(config.defaultLanguage)) + .then(() => { + localStorage.setItem('locale', config.defaultLanguage) + refresh(true) + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error( + 'Cannot load language "' + config.defaultLanguage + '": ' + e, + ) + }) + } + }, [setLocale, refresh]) useChangeThemeColor() /* eslint-disable react/jsx-key */ return ( diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 0b8c256df..a860c85bb 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -28,7 +28,11 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' -import albumLists, { defaultAlbumList } from './albumLists' +import albumLists from './albumLists' +import { + getStoredDefaultView, + isResourceDefaultView, +} from '../personal/defaultViews' import config from '../config' import AlbumInfo from './AlbumInfo' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' @@ -220,8 +224,10 @@ const AlbumList = (props) => { // If it does not have filter/sort params (usually coming from Menu), // reload with correct filter/sort params if (!location.search) { - const type = - albumListType || localStorage.getItem('defaultView') || defaultAlbumList + const type = albumListType || getStoredDefaultView() + if (isResourceDefaultView(type)) { + return + } const listParams = albumLists[type] if (type === 'random') { refresh() diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index e2070deea..c3d795b26 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -272,18 +272,6 @@ const Player = () => { } }, []) - const onAudioSeeked = useCallback( - (info) => { - if (!info.isRadio && currentTrackId) { - const posMs = Math.floor(info.currentTime * 1000) - lastPositionMsRef.current = posMs - const state = audioInstance?.paused ? 'paused' : 'playing' - subsonic.reportPlayback(currentTrackId, posMs, state) - } - }, - [currentTrackId, audioInstance], - ) - const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), @@ -436,6 +424,35 @@ const Player = () => { } }, [isMobilePlayer, audioInstance]) + // Report every seek (including programmatic ones the library does not surface + // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag + // bursts into one report at the final position. + useEffect(() => { + if (!audioInstance) return + let timer = null + const flush = () => { + timer = null + if ( + !currentTrackIdRef.current || + playerStateRef.current?.current?.isRadio + ) { + return + } + const posMs = Math.floor((audioInstance.currentTime || 0) * 1000) + const state = audioInstance.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackIdRef.current, posMs, state) + } + const handleSeeked = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(flush, 250) + } + audioInstance.addEventListener('seeked', handleSeeked) + return () => { + if (timer) clearTimeout(timer) + audioInstance.removeEventListener('seeked', handleSeeked) + } + }, [audioInstance]) + return ( { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} - onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 268d3668d..f5004308b 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -137,8 +137,9 @@ const updateUser = async (params) => { data: userData, }) - // Then handle library associations for non-admin users - if (!userData.isAdmin && libraryIds !== undefined) { + // Then handle library associations for non-admin users. Only admins can call + // this endpoint; for self-edits the server manages library assignments + if (isAdmin() && !userData.isAdmin && libraryIds !== undefined) { await handleUserLibraryAssociation(userId, libraryIds) } diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js new file mode 100644 index 000000000..fbc82f969 --- /dev/null +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import wrapperDataProvider from './wrapperDataProvider' + +const { mockProvider, mockHttpClient } = vi.hoisted(() => ({ + mockProvider: { + update: vi.fn(), + create: vi.fn(), + getOne: vi.fn(), + }, + mockHttpClient: vi.fn(), +})) + +vi.mock('ra-data-json-server', () => ({ default: () => mockProvider })) +vi.mock('./httpClient', () => ({ default: mockHttpClient })) + +describe('wrapperDataProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mockProvider.update.mockResolvedValue({ data: { id: 'u1' } }) + mockProvider.create.mockResolvedValue({ data: { id: 'u1' } }) + mockHttpClient.mockResolvedValue({ json: [] }) + }) + + describe('update user', () => { + it('sets library associations when an admin edits a non-admin user', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ id: 'u1' }), + ) + expect(mockHttpClient).toHaveBeenCalledWith('/api/user/u1/library', { + method: 'PUT', + body: JSON.stringify({ libraryIds: [1] }), + }) + }) + + it('does not call the admin-only library endpoint when a non-admin edits their own profile', async () => { + localStorage.setItem('role', 'regular') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { + name: 'Sam', + isAdmin: false, + libraryIds: [1], + currentPassword: 'old', + password: 'new', + }, + }) + + expect(mockProvider.update).toHaveBeenCalled() + expect(mockHttpClient).not.toHaveBeenCalled() + }) + + it('does not set library associations when the edited user is an admin', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: true, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalled() + expect(mockHttpClient).not.toHaveBeenCalled() + }) + + it('strips libraryIds from the user update payload', async () => { + localStorage.setItem('role', 'admin') + + await wrapperDataProvider.update('user', { + id: 'u1', + data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, + }) + + expect(mockProvider.update).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ + data: { name: 'Sam', isAdmin: false }, + }), + ) + }) + }) +}) diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index 91f56b273..a7763cff3 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react' +import React, { useState, useCallback } from 'react' import PropTypes from 'prop-types' import { Field, Form } from 'react-final-form' import { useDispatch } from 'react-redux' @@ -13,8 +13,6 @@ import { createMuiTheme, useLogin, useNotify, - useRefresh, - useSetLocale, useTranslate, useVersion, } from 'react-admin' @@ -24,7 +22,6 @@ import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' import config from '../config' import { clearQueue } from '../actions' -import { retrieveTranslation } from '../i18n' import { INSIGHTS_DOC_URL } from '../consts.js' const useStyles = makeStyles( @@ -101,8 +98,13 @@ const renderInput = ({ }) => ( @@ -402,27 +404,8 @@ Login.propTypes = { // the right theme const LoginWithTheme = (props) => { const theme = useCurrentTheme() - const setLocale = useSetLocale() - const refresh = useRefresh() const version = useVersion() - useEffect(() => { - if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { - retrieveTranslation(config.defaultLanguage) - .then(() => { - setLocale(config.defaultLanguage).then(() => { - localStorage.setItem('locale', config.defaultLanguage) - }) - refresh(true) - }) - .catch((e) => { - throw new Error( - 'Cannot load language "' + config.defaultLanguage + '": ' + e, - ) - }) - } - }, [refresh, setLocale]) - return ( diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index 67018d2bb..c8e07328f 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -13,21 +13,10 @@ import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' const Progress = (props) => { - const { setLinked, setCheckingLink, apiKey } = props + const { setLinked, setCheckingLink, openedTab } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 - const openedTab = useRef() - - useEffect(() => { - const callbackEndpoint = baseUrl( - `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, - ) - const callbackUrl = `${window.location.origin}${callbackEndpoint}` - openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, - ) - }, [apiKey]) const endChecking = (success) => { linkCheckDelay = null @@ -76,6 +65,7 @@ export const LastfmScrobbleToggle = (props) => { const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) const [apiKey, setApiKey] = useState(false) + const openedTab = useRef() useEffect(() => { httpClient('/api/lastfm/link') @@ -88,9 +78,42 @@ export const LastfmScrobbleToggle = (props) => { }) }, [setLinked, setApiKey]) + const startLink = () => { + // Open the tab synchronously so popup blockers attribute it to the click. + let tab + try { + tab = openInNewTab('about:blank') + } catch { + notify('message.lastfmLinkFailure', 'warning') + return + } + openedTab.current = tab + setCheckingLink(true) + httpClient('/api/lastfm/link') + .then((response) => { + const linkToken = response.json.linkToken + if (!linkToken) { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + return + } + const callbackEndpoint = baseUrl( + `/api/lastfm/link/callback?uid=${encodeURIComponent(linkToken)}`, + ) + const callbackUrl = `${window.location.origin}${callbackEndpoint}` + tab.location.href = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}` + }) + .catch(() => { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + }) + } + const toggleScrobble = () => { if (!linked) { - setCheckingLink(true) + startLink() } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) .then(() => { @@ -121,7 +144,7 @@ export const LastfmScrobbleToggle = (props) => { )} {!apiKey && ( diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx index 71c87305c..e90fd65bc 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.jsx @@ -1,13 +1,10 @@ import { SelectInput, useTranslate } from 'react-admin' -import albumLists, { defaultAlbumList } from '../album/albumLists' +import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = localStorage.getItem('defaultView') || defaultAlbumList - const choices = Object.keys(albumLists).map((type) => ({ - id: type, - name: translate(`resources.album.lists.${type}`), - })) + const current = getStoredDefaultView() + const choices = getDefaultViewChoices(translate) return ( + resourceDefaultViews.includes(defaultView) + +export const getDefaultViewChoices = (translate) => [ + ...Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })), + ...resourceDefaultViews.map((resource) => ({ + id: resource, + name: translate(`resources.${resource}.name`, { smart_count: 2 }), + })), +] + +export const getStoredDefaultView = () => + localStorage.getItem('defaultView') || defaultAlbumList diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js new file mode 100644 index 000000000..44057a736 --- /dev/null +++ b/ui/src/personal/defaultViews.test.js @@ -0,0 +1,48 @@ +import { + getDefaultViewChoices, + getStoredDefaultView, + isResourceDefaultView, + resourceDefaultViews, +} from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' + +describe('defaultViews', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('includes album lists and top-level resource lists as choices', () => { + const choices = getDefaultViewChoices((key, options) => + options?.smart_count ? `${key}:${options.smart_count}` : key, + ) + + expect(choices.map((choice) => choice.id)).toEqual([ + ...Object.keys(albumLists), + ...resourceDefaultViews, + ]) + expect(choices).toEqual( + expect.arrayContaining([ + { id: 'artist', name: 'resources.artist.name:2' }, + { id: 'song', name: 'resources.song.name:2' }, + { id: 'playlist', name: 'resources.playlist.name:2' }, + ]), + ) + }) + + it('identifies resource-backed default views', () => { + expect(isResourceDefaultView('artist')).toBe(true) + expect(isResourceDefaultView('song')).toBe(true) + expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('recentlyAdded')).toBe(false) + }) + + it('falls back to the default album list when no default view is stored', () => { + expect(getStoredDefaultView()).toBe(defaultAlbumList) + }) + + it('returns the stored default view', () => { + localStorage.setItem('defaultView', 'playlist') + + expect(getStoredDefaultView()).toBe('playlist') + }) +}) diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index d928af549..d44992d0c 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -143,9 +143,11 @@ const SongList = (props) => { return { album: isDesktop && , artist: , - composer: , + composer: , albumArtist: , - trackNumber: isDesktop && , + trackNumber: isDesktop && ( + + ), playCount: isDesktop && ( ), diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index 74f7d3fd4..55205baf3 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -192,6 +192,11 @@ export default { paddingBottom: '1rem', }, }, + RaConfirm: { + confirmPrimary: { + color: '#fff', + }, + }, RaDeleteWithConfirmButton: { deleteButton: { color: '#fff !important', diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js new file mode 100644 index 000000000..84c8d2d7f --- /dev/null +++ b/ui/src/themes/catppuccinLatte.css.js @@ -0,0 +1,203 @@ +const stylesheet = ` + .react-jinke-music-player-main.light-theme svg, + .react-jinke-music-player .music-player-controller, + .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { + color: #6c6f85; + stroke: #6c6f85; + } + + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #7c7f93; + } + + .react-jinke-music-player-main.light-theme svg:active, + .react-jinke-music-player-main.light-theme svg:hover { + color: #7c7f93; + } + + .react-jinke-music-player-mobile-play-model-tip, + .react-jinke-music-player-main.light-theme .play-mode-title { + background-color: #6c6f85; + color: #eff1f5; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #6c6f85; + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #6c6f85; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #6c6f85; + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #6c6f85; + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #6c6f85 !important; + } + + .react-jinke-music-player-main .loading svg { + color: #6c6f85 !important; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .rc-slider-rail, + .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #6c6f85 !important; + border: 1px solid #6c6f85; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #e6e9ef; + color: #4c4f69; + box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); + } + + .react-jinke-music-player-main.light-theme .music-player-panel { + color: #4c4f69; + } + + .audio-lists-panel { + background-color: #e6e9ef; + bottom: 6.25rem; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: rgba(76, 79, 105, 0.08); + } + + .audio-lists-panel-header { + border-bottom: 1px solid #ccd0da; + box-shadow: none; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; + } + + .react-jinke-music-player-main.light-theme .audio-lists-panel-header { + background-color: #e6e9ef; + color: #4c4f69; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + color: #4c4f69; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #6c6f85; /* subtext0 */ + -webkit-text-stroke: 0.35px #eff1f5; + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #6c6f85 !important; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #6c6f85; + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #6c6f85; + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #dce0e8; /* surface1 */ + } + + /* Mobile */ + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + background-color: #e6e9ef; + border-color: #e6e9ef; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + color: #6c6f85; + } + + .react-jinke-music-player .music-player-controller.music-player-playing:before { + border: 1px solid rgba(76, 79, 105, 0.18); + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + background: rgba(108, 111, 133, 0.2); + color: #eff1f5; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, + .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #6c6f85; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinLatte.js b/ui/src/themes/catppuccinLatte.js new file mode 100644 index 000000000..3624cd853 --- /dev/null +++ b/ui/src/themes/catppuccinLatte.js @@ -0,0 +1,104 @@ +import stylesheet from './catppuccinLatte.css.js' + +export default { + themeName: 'Catppuccin Latte', + palette: { + primary: { main: '#8839ef' }, // mauve + secondary: { + main: '#ccd0da', // surface0 + contrastText: '#4c4f69', // text + }, + type: 'light', + background: { + default: '#eff1f5', // base + }, + }, + + overrides: { + MuiPaper: { + root: { + color: '#4c4f69', // text + backgroundColor: '#e6e9ef', // mantle + }, + }, + + MuiButton: { + textPrimary: { + color: '#1e66f5', // blue + }, + textSecondary: { + color: '#4c4f69', // text + }, + }, + + MuiChip: { + clickable: { + background: '#ccd0da', // surface0 + }, + }, + + MuiFormGroup: { + root: { + color: '#4c4f69', + }, + }, + + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#d20f39', // red + }, + }, + }, + }, + + MuiTableHead: { + root: { + color: '#4c4f69', + background: '#e6e9ef', + }, + }, + + MuiTableCell: { + root: { + color: '#4c4f69', + background: '#e6e9ef !important', + }, + head: { + color: '#4c4f69', + background: '#e6e9ef !important', + }, + }, + + NDLogin: { + systemNameLink: { + color: '#8839ef', // mauve + }, + icon: {}, + welcome: { + color: '#4c4f69', + }, + card: { + minWidth: 300, + background: '#eff1f5', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #ccd0da', + }, + }, + + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(255 255 255 / 72%), rgb(239 241 245))!important', + }, + }, + }, + + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js index dc1f64041..f482451b2 100644 --- a/ui/src/themes/gruvboxDark.css.js +++ b/ui/src/themes/gruvboxDark.css.js @@ -5,7 +5,7 @@ const stylesheet = ` } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #458588 + background-color: #ebdbb2 } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { @@ -50,6 +50,13 @@ const stylesheet = ` .MuiCheckbox-colorSecondary.Mui-checked { color: #458588 !important } +.react-jinke-music-player-main .music-player-panel svg { + color: #ebdbb2; + fill: #ebdbb2; +} +.react-jinke-music-player-main .music-player-panel button { + color: #ebdbb2; +} ` export default stylesheet diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 20f5c732f..0f4cbd7c4 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -14,22 +14,34 @@ export default { background: { default: '#282828', }, + text: { + primary: '#ebdbb2', + secondary: '#a89984', + }, }, overrides: { MuiPaper: { root: { color: '#ebdbb2', backgroundColor: '#3c3836', - MuiSnackbarContent: { - root: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - message: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - }, + }, + }, + MuiSnackbarContent: { + root: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + message: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + }, + MuiTypography: { + root: { + color: '#ebdbb2', + }, + colorTextSecondary: { + color: '#a89984', }, }, MuiButton: { @@ -45,6 +57,19 @@ export default { color: '#ebdbb2', }, }, + MuiListItemIcon: { + root: { + color: '#ebdbb2', + }, + }, + MuiListItemText: { + primary: { + color: '#ebdbb2', + }, + secondary: { + color: '#a89984', + }, + }, MuiChip: { clickable: { background: '#49483e', @@ -57,11 +82,10 @@ export default { }, MuiFormHelperText: { root: { - Mui: { - error: { - color: '#cc241d', - }, - }, + color: '#ebdbb2', + }, + error: { + color: '#cc241d', }, }, MuiTableHead: { @@ -113,6 +137,17 @@ export default { 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', }, }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#ebdbb2', + }, + albumSubtitle: { + color: '#a89984', + }, + }, }, player: { theme: 'dark', diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index f65948438..98705da30 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -9,12 +9,20 @@ import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import CatppuccinLatteTheme from './catppuccinLatte' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' import NutballTheme from './nutball' +import RosePineTheme from './rosePine' +import RosePineDawnTheme from './rosePineDawn' +import RosePineMoonTheme from './rosePineMoon' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' +import MoonbaseAlphaTheme from './moonbaseAlpha' +import MoonbaseBravoTheme from './moonbaseBravo' +import TokyoNightLightTheme from './tokyoNightLight' +import TokyoNightTheme from './tokyoNight' export default { // Classic default themes @@ -24,6 +32,7 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, CatppuccinMacchiatoTheme, + CatppuccinLatteTheme, DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, @@ -31,10 +40,17 @@ export default { GruvboxDarkTheme, LigeraTheme, MonokaiTheme, + MoonbaseAlphaTheme, + MoonbaseBravoTheme, NautilineTheme, NordTheme, NuclearTheme, NutballTheme, + RosePineDawnTheme, + RosePineMoonTheme, + RosePineTheme, SpotifyTheme, SquiddiesGlassTheme, + TokyoNightLightTheme, + TokyoNightTheme, } diff --git a/ui/src/themes/moonbaseAlpha.css.js b/ui/src/themes/moonbaseAlpha.css.js new file mode 100644 index 000000000..757cfc03c --- /dev/null +++ b/ui/src/themes/moonbaseAlpha.css.js @@ -0,0 +1,63 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #9a7420 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #b8862e; + border-color: #9a7420 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #c9b896; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #b8862e +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #9a7420 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #9a7420 !important +} + +.react-jinke-music-player-main .rc-slider-rail { + background-color: #ddd7cc !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #1a1917 !important +} + +.react-jinke-music-player-main .music-player-panel { + color: #1a1917 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #9a7420 !important +} + +.music-player-lyric { + color: #9a7420 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #9a7420 +} +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #9a7420 +} + +.progress-bar-content .audio-title a { + color: #1a1917 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #b8862e !important +} +` +export default stylesheet diff --git a/ui/src/themes/moonbaseAlpha.js b/ui/src/themes/moonbaseAlpha.js new file mode 100644 index 000000000..51d8a2696 --- /dev/null +++ b/ui/src/themes/moonbaseAlpha.js @@ -0,0 +1,90 @@ +import stylesheet from './moonbaseAlpha.css.js' + +export default { + themeName: 'Moonbase - Alpha', + palette: { + primary: { + main: '#9a7420', + }, + secondary: { + main: '#ede8df', + contrastText: '#1a1917', + }, + type: 'light', + background: { + default: '#f5f0e8', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#1a1917', + backgroundColor: '#faf8f4', + }, + }, + MuiButton: { + textPrimary: { + color: '#9a7420', + }, + textSecondary: { + color: '#1a1917', + }, + }, + MuiChip: { + clickable: { + background: '#ede8df', + }, + }, + MuiFormGroup: { + root: { + color: '#1a1917', + }, + }, + MuiFormHelperText: { + error: { + color: '#b04a2e', + }, + }, + MuiTableHead: { + root: { + color: '#6b635a', + background: '#f5f0e8 !important', + }, + }, + MuiTableCell: { + root: { + color: '#1a1917', + background: '#faf8f4 !important', + }, + head: { + color: '#6b635a', + background: '#f5f0e8 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#9a7420', + }, + welcome: { + color: '#1a1917', + }, + card: { + minWidth: 300, + background: '#faf8f4', + }, + button: { + boxShadow: '3px 3px 5px rgba(0, 0, 0, 0.12)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(245, 240, 232, 0.72), #faf8f4)!important', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/moonbaseBravo.css.js b/ui/src/themes/moonbaseBravo.css.js new file mode 100644 index 000000000..580b054cc --- /dev/null +++ b/ui/src/themes/moonbaseBravo.css.js @@ -0,0 +1,63 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #d4a039 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #d4a039; + border-color: #b8862e +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #d4a039; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #d4a039 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #d4a039 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #d4a039 !important +} + +.react-jinke-music-player-main .rc-slider-rail { + background-color: #2a2a27 !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #e5ddd3 !important +} + +.react-jinke-music-player-main .music-player-panel { + color: #e5ddd3 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #d4a039 !important +} + +.music-player-lyric { + color: #d4a039 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #d4a039 +} +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #d4a039 +} + +.progress-bar-content .audio-title a { + color: #e5ddd3 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #d4a039 !important +} +` +export default stylesheet diff --git a/ui/src/themes/moonbaseBravo.js b/ui/src/themes/moonbaseBravo.js new file mode 100644 index 000000000..87585df29 --- /dev/null +++ b/ui/src/themes/moonbaseBravo.js @@ -0,0 +1,90 @@ +import stylesheet from './moonbaseBravo.css.js' + +export default { + themeName: 'Moonbase - Bravo', + palette: { + primary: { + main: '#d4a039', + }, + secondary: { + main: '#1e1e1c', + contrastText: '#e5ddd3', + }, + type: 'dark', + background: { + default: '#0a0a09', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e5ddd3', + backgroundColor: '#141413', + }, + }, + MuiButton: { + textPrimary: { + color: '#d4a039', + }, + textSecondary: { + color: '#e5ddd3', + }, + }, + MuiChip: { + clickable: { + background: '#1e1e1c', + }, + }, + MuiFormGroup: { + root: { + color: '#e5ddd3', + }, + }, + MuiFormHelperText: { + error: { + color: '#c45c3c', + }, + }, + MuiTableHead: { + root: { + color: '#8a8278', + background: '#0a0a09 !important', + }, + }, + MuiTableCell: { + root: { + color: '#e5ddd3', + background: '#141413 !important', + }, + head: { + color: '#8a8278', + background: '#0a0a09 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#d4a039', + }, + welcome: { + color: '#e5ddd3', + }, + card: { + minWidth: 300, + background: '#1e1e1c', + }, + button: { + boxShadow: '3px 3px 5px #0a0a09', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(10, 10, 9, 0.72), #141413)!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js index 65ded5fc5..0c13dc0ec 100644 --- a/ui/src/themes/nautiline.js +++ b/ui/src/themes/nautiline.js @@ -627,7 +627,6 @@ const NautilineTheme = { root: { [`@media (max-width: ${breakpoints.xs}px)`]: { padding: '0.7em', - width: '100%', minWidth: 'unset', }, }, diff --git a/ui/src/themes/rosePine.css.js b/ui/src/themes/rosePine.css.js new file mode 100644 index 000000000..a1c21f562 --- /dev/null +++ b/ui/src/themes/rosePine.css.js @@ -0,0 +1,148 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #c4a7e7 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #ebbcba +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #ebbcba; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #ebbcba +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #ebbcba +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #ebbcba !important +} + +.react-jinke-music-player-main .loading svg { + color: #ebbcba !important +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: none; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #1f1d2e; + color: #e0def4; + box-shadow: 0 0 8px rgba(25, 23, 36, 0.35); +} + +.audio-lists-panel { + background-color: #1f1d2e; + bottom: 6.25rem; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #908caa; + -webkit-text-stroke: 0.5px #191724; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #908caa !important; +} + +.audio-lists-panel-header { + border-bottom:1px solid #26233a; + box-shadow:none; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #ebbcba +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #ebbcba +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #26233a; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + background-color: #1f1d2e; + border-color: #1f1d2e; + box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; + color: #ebbcba; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(196,167,231,.3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #ebbcba; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/rosePine.js b/ui/src/themes/rosePine.js new file mode 100644 index 000000000..547a1f764 --- /dev/null +++ b/ui/src/themes/rosePine.js @@ -0,0 +1,108 @@ +import stylesheet from './rosePine.css.js' + +export default { + themeName: 'Rosé Pine', + palette: { + primary: { + main: '#ebbcba', + }, + secondary: { + main: '#1f1d2e', + contrastText: '#e0def4', + }, + type: 'dark', + background: { + default: '#191724', + paper: '#1f1d2e', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e0def4', + backgroundColor: '#1f1d2e', + }, + }, + MuiButton: { + textPrimary: { + color: '#31748f', + }, + textSecondary: { + color: '#e0def4', + }, + }, + MuiIconButton: { + colorSecondary: { + color: '#6e6a86', + }, + }, + MuiChip: { + clickable: { + background: '#26233a', + }, + }, + MuiCheckbox: { + colorSecondary: { + color: '#6e6a86', + '&$checked': { + color: '#ebbcba', + }, + }, + }, + MuiFormGroup: { + root: { + color: '#e0def4', + }, + }, + MuiFormHelperText: { + root: { + '&$error': { + color: '#eb6f92', + }, + }, + }, + MuiTableHead: { + root: { + color: '#e0def4', + background: '#1f1d2e', + }, + }, + MuiTableCell: { + root: { + color: '#e0def4', + background: '#1f1d2e !important', + }, + head: { + color: '#e0def4', + background: '#1f1d2e !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#ebbcba', + }, + icon: {}, + welcome: { + color: '#e0def4', + }, + card: { + minWidth: 300, + background: '#191724', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px rgba(25, 23, 36, 0.35)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(25, 23, 36, 0.72), rgb(25, 23, 36))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/rosePineDawn.css.js b/ui/src/themes/rosePineDawn.css.js new file mode 100644 index 000000000..e3c882815 --- /dev/null +++ b/ui/src/themes/rosePineDawn.css.js @@ -0,0 +1,198 @@ +const stylesheet = ` + .react-jinke-music-player-main.light-theme svg, + .react-jinke-music-player .music-player-controller, + .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { + color: #797593; + stroke: #797593; + } + + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #907aa9; + } + + .react-jinke-music-player-main.light-theme svg:active, + .react-jinke-music-player-main.light-theme svg:hover { + color: #907aa9; + } + + .react-jinke-music-player-mobile-play-model-tip, + .react-jinke-music-player-main.light-theme .play-mode-title { + background-color: #d7827e; + color: #faf4ed; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #d7827e; + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #d7827e; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #d7827e; + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #d7827e; + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #d7827e !important; + } + + .react-jinke-music-player-main .loading svg { + color: #d7827e !important; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: none; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .rc-slider-rail, + .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #d7827e !important; + border: 1px solid #d7827e; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #fffaf3; + color: #464261; + box-shadow: 0 0 8px rgba(70, 66, 97, 0.12); + } + + .react-jinke-music-player-main.light-theme .music-player-panel { + color: #464261; + } + + .audio-lists-panel { + background-color: #fffaf3; + bottom: 6.25rem; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-header { + border-bottom: 1px solid #f2e9e1; + box-shadow: none; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; + } + + .react-jinke-music-player-main.light-theme .audio-lists-panel-header { + background-color: #fffaf3; + color: #464261; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + color: #464261; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #797593; + -webkit-text-stroke: 0.35px #faf4ed; + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #797593 !important; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #d7827e; + } + + .audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, + .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #d7827e; + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #f2e9e1; + } + + /* Mobile */ + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + background-color: #fffaf3; + border-color: #fffaf3; + box-shadow: + rgba(70, 66, 97, 0.12) 0px 4px 6px, + rgba(70, 66, 97, 0.08) 0px 5px 7px; + color: #d7827e; + } + + .react-jinke-music-player .music-player-controller.music-player-playing:before { + border: 1px solid rgba(70, 66, 97, 0.18); + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + background: rgba(215, 130, 126, 0.2); + color: #faf4ed; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, + .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #d7827e; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/rosePineDawn.js b/ui/src/themes/rosePineDawn.js new file mode 100644 index 000000000..aea903c1a --- /dev/null +++ b/ui/src/themes/rosePineDawn.js @@ -0,0 +1,108 @@ +import stylesheet from './rosePineDawn.css.js' + +export default { + themeName: 'Rosé Pine Dawn', + palette: { + primary: { + main: '#d7827e', + }, + secondary: { + main: '#fffaf3', + contrastText: '#464261', + }, + type: 'light', + background: { + default: '#faf4ed', + paper: '#fffaf3', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#464261', + backgroundColor: '#fffaf3', + }, + }, + MuiButton: { + textPrimary: { + color: '#286983', + }, + textSecondary: { + color: '#464261', + }, + }, + MuiIconButton: { + colorSecondary: { + color: '#9893a5', + }, + }, + MuiChip: { + clickable: { + background: '#f2e9e1', + }, + }, + MuiCheckbox: { + colorSecondary: { + color: '#9893a5', + '&$checked': { + color: '#d7827e', + }, + }, + }, + MuiFormGroup: { + root: { + color: '#464261', + }, + }, + MuiFormHelperText: { + root: { + '&$error': { + color: '#b4637a', + }, + }, + }, + MuiTableHead: { + root: { + color: '#464261', + background: '#fffaf3', + }, + }, + MuiTableCell: { + root: { + color: '#464261', + background: '#fffaf3 !important', + }, + head: { + color: '#464261', + background: '#fffaf3 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#d7827e', + }, + icon: {}, + welcome: { + color: '#464261', + }, + card: { + minWidth: 300, + background: '#faf4ed', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px rgba(87, 82, 121, 0.12)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(250, 244, 237, 0.72), rgb(250, 244, 237))!important', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/rosePineMoon.css.js b/ui/src/themes/rosePineMoon.css.js new file mode 100644 index 000000000..0eb7aaf57 --- /dev/null +++ b/ui/src/themes/rosePineMoon.css.js @@ -0,0 +1,148 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #c4a7e7 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #ea9a97 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #ea9a97; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #ea9a97 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #ea9a97 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #ea9a97 !important +} + +.react-jinke-music-player-main .loading svg { + color: #ea9a97 !important +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: none; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #2a273f; + color: #e0def4; + box-shadow: 0 0 8px rgba(35, 33, 54, 0.35); +} + +.audio-lists-panel { + background-color: #2a273f; + bottom: 6.25rem; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #908caa; + -webkit-text-stroke: 0.5px #232136; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #908caa !important; +} + +.audio-lists-panel-header { + border-bottom:1px solid #393552; + box-shadow:none; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #ea9a97 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #ea9a97 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #393552; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + background-color: #2a273f; + border-color: #2a273f; + box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; + color: #ea9a97; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(196,167,231,.3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #ea9a97; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/rosePineMoon.js b/ui/src/themes/rosePineMoon.js new file mode 100644 index 000000000..facf09446 --- /dev/null +++ b/ui/src/themes/rosePineMoon.js @@ -0,0 +1,108 @@ +import stylesheet from './rosePineMoon.css.js' + +export default { + themeName: 'Rosé Pine Moon', + palette: { + primary: { + main: '#ea9a97', + }, + secondary: { + main: '#2a273f', + contrastText: '#e0def4', + }, + type: 'dark', + background: { + default: '#232136', + paper: '#2a273f', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e0def4', + backgroundColor: '#2a273f', + }, + }, + MuiButton: { + textPrimary: { + color: '#3e8fb0', + }, + textSecondary: { + color: '#e0def4', + }, + }, + MuiIconButton: { + colorSecondary: { + color: '#6e6a86', + }, + }, + MuiChip: { + clickable: { + background: '#393552', + }, + }, + MuiCheckbox: { + colorSecondary: { + color: '#6e6a86', + '&$checked': { + color: '#ea9a97', + }, + }, + }, + MuiFormGroup: { + root: { + color: '#e0def4', + }, + }, + MuiFormHelperText: { + root: { + '&$error': { + color: '#eb6f92', + }, + }, + }, + MuiTableHead: { + root: { + color: '#e0def4', + background: '#2a273f', + }, + }, + MuiTableCell: { + root: { + color: '#e0def4', + background: '#2a273f !important', + }, + head: { + color: '#e0def4', + background: '#2a273f !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#ea9a97', + }, + icon: {}, + welcome: { + color: '#e0def4', + }, + card: { + minWidth: 300, + background: '#232136', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px rgba(35, 33, 54, 0.35)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(35, 33, 54, 0.72), rgb(35, 33, 54))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/tokyoNight.css.js b/ui/src/themes/tokyoNight.css.js new file mode 100644 index 000000000..882fcd3eb --- /dev/null +++ b/ui/src/themes/tokyoNight.css.js @@ -0,0 +1,143 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #7aa2f7 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #7aa2f7 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #7aa2f7; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #7aa2f7 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #7aa2f7 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #7aa2f7 !important +} + +.react-jinke-music-player-main .loading svg { + color: #7aa2f7 !important +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #24283b; + color: #c0caf5; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +.audio-lists-panel { + background-color: #24283b; + bottom: 6.25rem; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); +} + +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #292e42; +} + +.audio-lists-panel-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: none; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #c0caf5; + -webkit-text-stroke: 0.5px #1a1b26; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #7aa2f7 !important; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #7aa2f7 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #7aa2f7 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #7aa2f7; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122, 162, 247, .3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #7aa2f7; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js new file mode 100644 index 000000000..07d372a6b --- /dev/null +++ b/ui/src/themes/tokyoNight.js @@ -0,0 +1,382 @@ +import stylesheet from './tokyoNight.css.js' + +const background = '#1a1b26' +const surface = '#24283b' +const currentLine = '#292e42' +const foreground = '#c0caf5' +const comment = '#565f89' +const blue = '#7aa2f7' +const cyan = '#7dcfff' +const purple = '#bb9af7' +const red = '#f7768e' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Tokyo Night', + palette: { + primary: { + main: blue, + }, + secondary: { + main: purple, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'dark', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${blue} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: blue, + }, + '&$checked + $track': { + backgroundColor: blue, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: purple, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: blue, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #15161e', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(26 27 38 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/tokyoNightLight.css.js b/ui/src/themes/tokyoNightLight.css.js new file mode 100644 index 000000000..a22c82d03 --- /dev/null +++ b/ui/src/themes/tokyoNightLight.css.js @@ -0,0 +1,123 @@ +const stylesheet = ` +.react-jinke-music-player-main.light-theme .loading svg { + color: #2e7de9; + font-size: 24px +} + +.react-jinke-music-player-mobile-play-model-tip { + background-color: #2e7de9; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #2e7de9 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer { + color: #2e7de9 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #2e7de9 +} + +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb { + background-color: #2e7de9; +} + +.react-jinke-music-player-main.light-theme svg { + color: #3760bf +} + +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: rgba(55, 96, 191, .12) !important +} + +.react-jinke-music-player-main.light-theme .music-player-controller { + background-color: #d5d6db; + border-color: #d5d6db +} + +.react-jinke-music-player-main.light-theme .music-player-panel { + background-color: #d5d6db; + box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05); + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .music-player-panel .img-content { + box-shadow: 0 0 10px #c4c8da +} + +.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar { + background-color: rgba(55, 96, 191, .08) !important +} + +.react-jinke-music-player-main.light-theme .rc-switch { + color: #fff +} + +.react-jinke-music-player-main.light-theme .rc-switch:after { + background-color: #fff +} + +.react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #2e7de9 !important; + border: 1px solid #2e7de9 +} + +.react-jinke-music-player-main.light-theme .rc-switch-inner { + color: #fff +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: #e1e2e7 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover { + background-color: #ebebed; + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel { + background-color: #d5d6db; + box-shadow: 0 0 2px #c4c8da; + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item { + background-color: #d5d6db +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) { + background-color: #dadbe0 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing { + background-color: #c4c8da !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { + color: #2e7de9 !important +} +` + +export default stylesheet diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js new file mode 100644 index 000000000..f84cd0be9 --- /dev/null +++ b/ui/src/themes/tokyoNightLight.js @@ -0,0 +1,382 @@ +import stylesheet from './tokyoNightLight.css.js' + +const background = '#e1e2e7' +const surface = '#d5d6db' +const currentLine = '#c4c8da' +const foreground = '#3760bf' +const comment = '#848cb5' +const blue = '#2e7de9' +const cyan = '#007197' +const purple = '#9854f1' +const red = '#f52a65' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Tokyo Night Light', + palette: { + primary: { + main: blue, + }, + secondary: { + main: purple, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'light', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.15) 0px 4px 6px, rgba(15, 17, 21, 0.08) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${blue} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: blue, + }, + '&$checked + $track': { + backgroundColor: blue, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 20%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: purple, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: blue, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #a8aecb', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(225 226 231 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index 2283dd8bc..d8302a9f9 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -96,9 +96,10 @@ const UserEdit = (props) => { }) permissions === 'admin' ? redirect('/user') : refresh() } catch (error) { - if (error.body.errors) { + if (error?.body?.errors) { return error.body.errors } + notify('ra.page.error', 'warning') } }, [mutate, notify, permissions, redirect, refresh], diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 75a9a1ada..1d8290569 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -27,6 +27,14 @@ const adminUser = { isAdmin: true, } +const hooks = vi.hoisted(() => ({ + save: null, + mutate: vi.fn(), + notify: vi.fn(), + redirect: vi.fn(), + refresh: vi.fn(), +})) + // Mock React-Admin completely with simpler implementations vi.mock('react-admin', () => ({ Edit: ({ children, title }) => ( @@ -35,9 +43,10 @@ vi.mock('react-admin', () => ({ {children} ), - SimpleForm: ({ children }) => ( -
{children}
- ), + SimpleForm: ({ children, save }) => { + hooks.save = save + return
{children}
+ }, TextInput: ({ source }) => , BooleanInput: ({ source }) => ( @@ -54,10 +63,10 @@ vi.mock('react-admin', () => ({ Typography: ({ children }) =>

{children}

, required: () => () => null, email: () => () => null, - useMutation: () => [vi.fn()], - useNotify: () => vi.fn(), - useRedirect: () => vi.fn(), - useRefresh: () => vi.fn(), + useMutation: () => [hooks.mutate], + useNotify: () => hooks.notify, + useRedirect: () => hooks.redirect, + useRefresh: () => hooks.refresh, usePermissions: () => ({ permissions: 'admin' }), useTranslate: () => (key) => key, })) @@ -127,4 +136,60 @@ describe('', () => { expect(screen.getByTestId('text-input-name')).toBeInTheDocument() expect(screen.getByTestId('text-input-email')).toBeInTheDocument() }) + + describe('save', () => { + beforeEach(() => { + vi.clearAllMocks() + hooks.save = null + }) + + it('notifies success and redirects when the update succeeds', async () => { + hooks.mutate.mockResolvedValue({ data: defaultUser }) + render() + + await hooks.save({ id: 'user1', name: 'New Name' }) + + expect(hooks.notify).toHaveBeenCalledWith( + 'resources.user.notifications.updated', + 'info', + { smart_count: 1 }, + ) + expect(hooks.redirect).toHaveBeenCalledWith('/user') + }) + + it('returns field errors when the update fails validation', async () => { + const fieldErrors = { currentPassword: 'ra.validation.required' } + hooks.mutate.mockRejectedValue({ body: { errors: fieldErrors } }) + render() + + const result = await hooks.save({ id: 'user1' }) + + expect(result).toEqual(fieldErrors) + expect(hooks.notify).not.toHaveBeenCalledWith( + 'resources.user.notifications.updated', + 'info', + { smart_count: 1 }, + ) + }) + + it('notifies an error when the update fails without field errors', async () => { + hooks.mutate.mockRejectedValue(new Error('Forbidden')) + render() + + await hooks.save({ id: 'user1' }) + + expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(hooks.redirect).not.toHaveBeenCalled() + }) + + it('notifies an error when the update rejects with a non-object error', async () => { + hooks.mutate.mockRejectedValue(undefined) + render() + + await hooks.save({ id: 'user1' }) + + expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(hooks.redirect).not.toHaveBeenCalled() + }) + }) }) diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index 1fe448f84..9ab07cf18 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -28,7 +28,7 @@ func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileC b.Fatal(err) } b.Cleanup(configtest.SetupConfig()) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) @@ -116,7 +116,7 @@ func BenchmarkConcurrentCacheRead(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) @@ -152,7 +152,7 @@ func BenchmarkConcurrentCacheMiss(b *testing.B) { wg.Add(n) // All goroutines request the SAME key (not yet cached) item := &benchItem{key: fmt.Sprintf("miss-%d", i)} - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) diff --git a/utils/cache/cached_http_client.go b/utils/cache/cached_http_client.go index 94d33100b..4eed243dd 100644 --- a/utils/cache/cached_http_client.go +++ b/utils/cache/cached_http_client.go @@ -75,8 +75,7 @@ func (c *HTTPClient) serializeReq(req *http.Request) string { } if req.Body != nil { bodyData, _ := io.ReadAll(req.Body) - bodyStr := base64.StdEncoding.EncodeToString(bodyData) - data.Body = &bodyStr + data.Body = new(base64.StdEncoding.EncodeToString(bodyData)) } j, _ := json.Marshal(&data) return string(j) diff --git a/utils/cache/cached_http_client_test.go b/utils/cache/cached_http_client_test.go index 1ec1a3a27..5f8b0029c 100644 --- a/utils/cache/cached_http_client_test.go +++ b/utils/cache/cached_http_client_test.go @@ -20,6 +20,8 @@ var _ = Describe("HTTPClient", func() { var header string BeforeEach(func() { + requestsReceived = 0 + header = "" ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestsReceived++ header = r.Header.Get("head") diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 5edc533f8..ed2374696 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -85,10 +85,11 @@ func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader R go func() { start := time.Now() - cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) + cache, sfs, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) fc.mutex.Lock() defer fc.mutex.Unlock() fc.cache = cache + fc.fs = sfs fc.disabled = cache == nil || err != nil log.Info("Finished initializing cache", "cache", fc.name, "maxSize", fc.cacheSize, "elapsedTime", time.Since(start)) fc.ready.Store(true) @@ -109,6 +110,7 @@ type fileCache struct { cacheFolder string maxItems int cache fscache.Cache + fs *spreadFS getReader ReadFunc disabled bool ready atomic.Bool @@ -177,6 +179,7 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { _ = fc.invalidate(ctx, key) } else { log.Trace(ctx, "File successfully stored in cache", "cache", fc.name, "key", key) + fc.markComplete(ctx, key) } }() } @@ -248,7 +251,18 @@ func copyAndClose(w io.WriteCloser, r io.Reader) error { return err } -func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) { +// markComplete records on disk that the entry for key was written in full, +// so it is eligible for adoption after a restart (see spreadFS.Reload). +func (fc *fileCache) markComplete(ctx context.Context, key string) { + if fc.fs == nil { + return + } + if err := fc.fs.MarkComplete(key); err != nil { + log.Warn(ctx, "Error writing cache completion marker", "cache", fc.name, "key", key, err) + } +} + +func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, *spreadFS, error) { size, err := humanize.ParseBytes(cacheSize) if err != nil { log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize, @@ -257,27 +271,27 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach } if size == 0 { log.Warn(fmt.Sprintf("%s cache disabled", name)) - return nil, nil + return nil, nil, nil } lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) h := fscache.NewLRUHaunterStrategy(lru) - cacheFolder = filepath.Join(conf.Server.CacheFolder, cacheFolder) + cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder) var fs *spreadFS log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size)) fs, err = NewSpreadFS(cacheFolder, 0755) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache FS", name), err) - return nil, err + return nil, nil, err } ck, err := fscache.NewCacheWithHaunter(fs, h) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache", name), err) - return nil, err + return nil, nil, err } ck.SetKeyMapper(fs.KeyMapper) - return ck, nil + return ck, fs, nil } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 72f4463d1..edcfbc6b9 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -28,14 +28,14 @@ var _ = Describe("File Caches", func() { configtest.SetupConfig() _ = os.RemoveAll(tmpDir) }) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) }) Describe("NewFileCache", func() { It("creates the cache folder", func() { Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil()) - _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test")) + _, err := os.Stat(filepath.Join(conf.Server.CacheFolder.String(), "test")) Expect(os.IsNotExist(err)).To(BeFalse()) }) @@ -104,6 +104,75 @@ var _ = Describe("File Caches", func() { Expect(called).To(BeTrue()) }) + It("writes a completion marker after a successful cache write", func() { + fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return strings.NewReader("complete-data"), nil + }) + s, err := fc.Get(context.Background(), &testArg{"markme"}) + Expect(err).To(BeNil()) + _, _ = io.ReadAll(s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) + Eventually(func() bool { + _, statErr := os.Stat(dataPath + ".complete") + return statErr == nil + }).Should(BeTrue()) + }) + + It("serves a concurrent reader from an in-progress write and marks complete once", func() { + pr, pw := io.Pipe() + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return pr, nil // slow, still-being-produced stream + }) + + // First Get → MISS; the cache starts copying pr into the entry in a goroutine. + s1, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + + // Write the first chunk so the entry exists with in-flight bytes, + // but leave the pipe open so the second reader can attach mid-stream. + // io.Pipe writes block until the cache goroutine reads them, giving us + // a deterministic happens-before: the entry is live before we call Get again. + _, err = pw.Write([]byte("hello ")) + Expect(err).To(BeNil()) + + // Second Get while the pipe is still open → attaches to the in-progress entry. + s2, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + + // Drain both readers concurrently; they race against the producer below. + ch1 := make(chan []byte, 1) + ch2 := make(chan []byte, 1) + go func() { b, _ := io.ReadAll(s1); ch1 <- b }() + go func() { b, _ := io.ReadAll(s2); ch2 <- b }() + + // Deliver the rest of the stream and close; both draining goroutines must see it. + _, err = pw.Write([]byte("world")) + Expect(err).To(BeNil()) + Expect(pw.Close()).To(Succeed()) + + Expect(string(<-ch1)).To(Equal("hello world")) + Expect(string(<-ch2)).To(Equal("hello world")) + _ = s1.Close() + _ = s2.Close() + + // Exactly one completion marker must appear. + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"live"}).Key()) + Eventually(func() bool { + _, e := os.Stat(dataPath + ".complete") + return e == nil + }).Should(BeTrue()) + + // Steady-state HIT: full data, Cached flag set. + s3, err := fc.Get(context.Background(), &testArg{"live"}) + Expect(err).To(BeNil()) + got3, _ := io.ReadAll(s3) + _ = s3.Close() + Expect(s3.Cached).To(BeTrue()) + Expect(string(got3)).To(Equal("hello world")) + }) + Context("reader errors", func() { When("creating a reader fails", func() { It("does not cache", func() { @@ -138,6 +207,77 @@ var _ = Describe("File Caches", func() { }) }) }) + + Context("crash leftover (issue #5636)", func() { + It("does not serve a partial file left on disk as a complete HIT", func() { + // First init: empties + writes the migration sentinel. + fc1 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return strings.NewReader("UNUSED"), nil + }) + _ = fc1 + + // Plant a partial file (no marker), simulating a killed process. + sfs, err := NewSpreadFS(filepath.Join(conf.Server.CacheFolder.String(), "test"), 0755) + Expect(err).To(BeNil()) + partialPath := sfs.KeyMapper((&testArg{"track"}).Key()) + Expect(os.MkdirAll(filepath.Dir(partialPath), 0755)).To(Succeed()) + Expect(os.WriteFile(partialPath, []byte("PARTIAL"), 0600)).To(Succeed()) + + // "Restart": a fresh cache over the same folder (sentinel present → strict). + getReaderCalled := false + fc2 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + getReaderCalled = true + return strings.NewReader("FULL-TRANSCODE"), nil + }) + + s, err := fc2.Get(context.Background(), &testArg{"track"}) + Expect(err).To(BeNil()) + data, _ := io.ReadAll(s) + _ = s.Close() + + Expect(getReaderCalled).To(BeTrue()) // re-transcoded, not served stale + Expect(string(data)).To(Equal("FULL-TRANSCODE")) + }) + }) + + Context("live error path still invalidates", func() { + It("leaves no data file and no marker after a mid-stream reader error", func() { + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return errFakeReader{errors.New("boom")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"err"}) + Expect(err).To(BeNil()) + _, _ = io.Copy(io.Discard, s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"err"}).Key()) + Eventually(func() bool { + _, e1 := os.Stat(dataPath) + _, e2 := os.Stat(dataPath + ".complete") + return os.IsNotExist(e1) && os.IsNotExist(e2) + }).Should(BeTrue()) + }) + + It("does not write a completion marker when the write fails after partial bytes", func() { + // Mimics a transcode that produces real output and then dies: + // the bytes land on disk, but the entry must NOT be marked complete. + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return &partialThenErrReader{data: []byte("PARTIAL-OUTPUT"), err: errors.New("transcoder died")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"partial"}) + Expect(err).To(BeNil()) + _, _ = io.Copy(io.Discard, s) + _ = s.Close() + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"partial"}).Key()) + // The marker must never appear for a failed write. Give the async + // writer time to finish, then assert the marker stays absent. + Consistently(func() bool { + _, e := os.Stat(dataPath + ".complete") + return os.IsNotExist(e) + }).Should(BeTrue()) + }) + }) }) }) @@ -148,3 +288,23 @@ func (t *testArg) Key() string { return t.s } type errFakeReader struct{ err error } func (e errFakeReader) Read([]byte) (int, error) { return 0, e.err } + +// partialThenErrReader emits data once, then fails — mimicking a transcoder +// that produces some output and then dies mid-stream. +type partialThenErrReader struct { + data []byte + err error + done bool +} + +func (r *partialThenErrReader) Read(p []byte) (int, error) { + if r.done { + return 0, r.err + } + r.done = true + return copy(p, r.data), nil +} + +func fcSpreadFS(fc *fileCache) *spreadFS { + return fc.fs +} diff --git a/utils/cache/file_haunter_test.go b/utils/cache/file_haunter_test.go index 47440cc22..6c5151abb 100644 --- a/utils/cache/file_haunter_test.go +++ b/utils/cache/file_haunter_test.go @@ -29,15 +29,15 @@ var _ = Describe("FileHaunter", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + // Use a short haunter period so cleanup runs promptly; the assertions + // below poll with Eventually instead of racing a fixed sleep. fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy( - cache.NewFileHaunter("", maxItems, maxSize, 300*time.Millisecond), + cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond), )) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCache.Clean) Expect(createTestFiles(fsCache)).To(Succeed()) - - <-time.After(400 * time.Millisecond) }) Context("When maxSize is defined", func() { @@ -46,24 +46,39 @@ var _ = Describe("FileHaunter", func() { }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(4)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") + // stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty. + // With maxSize=20, the haunter scrubs the empty file plus enough of + // the oldest files to bring the total size down to <= 20 bytes. + // Which files survive (and therefore the exact count) depends on + // access-time ordering, so we only assert the haunter's guarantees: + // the empty file is always scrubbed and the total size stays within + // the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + size, err := dirSize(cacheDir) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(size).To(BeNumerically("<=", maxSize)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) - XContext("When maxItems is defined", func() { + Context("When maxItems is defined", func() { BeforeEach(func() { maxItems = 3 }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(maxItems)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") - //Expect(fsCache.Exists("stream-1")).To(BeFalse(), "stream-1 should have been scrubbed") + // With maxItems=3, the haunter scrubs the empty file plus enough of + // the oldest files to bring the count within the limit. As above, the + // exact survivors depend on access-time ordering, so we assert the + // guaranteed invariants: the empty file is gone and the item count + // stays within the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + entries, readErr := os.ReadDir(cacheDir) + g.Expect(readErr).ToNot(HaveOccurred()) + g.Expect(len(entries)).To(BeNumerically("<=", maxItems)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) }) @@ -93,6 +108,26 @@ func createTestFiles(c *fscache.FSCache) error { return nil } +// dirSize returns the total size in bytes of all regular files in dir. +func dirSize(dir string) (uint64, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + var total uint64 + for _, e := range entries { + info, err := e.Info() + if err != nil { + return 0, err + } + if !info.Mode().IsRegular() { + continue + } + total += uint64(info.Size()) + } + return total, nil +} + func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser { r, w, _ := c.Get(name) _, _ = w.Write([]byte(contents)) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index 2f0ff4764..eb3c99995 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -9,7 +9,6 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - . "github.com/navidrome/navidrome/utils/gg" ) type SimpleCache[K comparable, V any] interface { @@ -119,7 +118,7 @@ func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Dur func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() - c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout))) + c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout))) } } diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 281e2bfab..647439790 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -14,6 +14,9 @@ import ( "github.com/navidrome/navidrome/log" ) +const completeMarkerSuffix = ".complete" +const sentinelName = ".nd-migrated" + type spreadFS struct { root string mode os.FileMode @@ -40,30 +43,83 @@ func NewSpreadFS(dir string, mode os.FileMode) (*spreadFS, error) { } func (sfs *spreadFS) Reload(f func(key string, name string)) error { + // On the first run after upgrade (no sentinel yet), pre-existing files have + // no completion marker. Migrate them instead of discarding them as partials, + // so a user's whole cache isn't wiped. After the sentinel exists, an unmarked + // file is a crash partial and is discarded. + sentinel := filepath.Join(sfs.root, sentinelName) + _, sErr := os.Stat(sentinel) + migrating := os.IsNotExist(sErr) + count := 0 - err := filepath.WalkDir(sfs.root, func(absoluteFilePath string, de fs.DirEntry, err error) error { + err := sfs.walkDataFiles(func(absoluteFilePath string) { + if _, statErr := os.Stat(sfs.markerPath(absoluteFilePath)); statErr != nil { + switch { + case migrating: + if mErr := sfs.MarkComplete(absoluteFilePath); mErr != nil { + log.Warn("Error migrating cache file", "file", absoluteFilePath, mErr) + } + case os.IsNotExist(statErr): + // No completion marker: this is a partial left by a crash. Discard it. + log.Debug("Removing incomplete cache file", "file", absoluteFilePath) + _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload + return + default: + // Marker may exist but is unreadable (transient I/O, permissions): + // skip adoption without destroying a possibly-valid entry. + log.Warn("Error reading cache completion marker", "file", absoluteFilePath, statErr) + return + } + } + f(absoluteFilePath, absoluteFilePath) + count++ + }) + if err != nil { + return err + } + + log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) + // Only record the migration as done after a clean walk, so a partial walk + // doesn't leave valid-but-unmarked files to be discarded on the next run. + if migrating { + if wErr := os.WriteFile(sentinel, nil, 0600); wErr != nil { + log.Warn("Error writing cache migration sentinel", "file", sentinel, wErr) + } + } + return nil +} + +// walkDataFiles visits every cache data file (named XX/XX/<40-hex>), skipping +// completion markers and opportunistically cleaning up orphaned ones. +func (sfs *spreadFS) walkDataFiles(visit func(absoluteFilePath string)) error { + return filepath.WalkDir(sfs.root, func(absoluteFilePath string, _ fs.DirEntry, err error) error { if err != nil { log.Error("Error loading cache", "dir", sfs.root, err) + return nil } path, err := filepath.Rel(sfs.root, absoluteFilePath) if err != nil { return nil //nolint:nilerr } + // Skip marker files; also clean orphan markers (data file gone). + if strings.HasSuffix(path, completeMarkerSuffix) { + dataPath := strings.TrimSuffix(absoluteFilePath, completeMarkerSuffix) + if _, statErr := os.Stat(dataPath); os.IsNotExist(statErr) { + _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload + } + return nil + } + // Skip if name is not in the format XX/XX/XXXXXXXXXXXX parts := strings.Split(path, string(os.PathSeparator)) if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 || len(parts[2]) != 40 { return nil } - f(absoluteFilePath, absoluteFilePath) - count++ + visit(absoluteFilePath) return nil }) - if err == nil { - log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) - } - return err } func (sfs *spreadFS) Create(name string) (stream.File, error) { @@ -79,7 +135,27 @@ func (sfs *spreadFS) Open(name string) (stream.File, error) { return os.Open(name) } +func (sfs *spreadFS) markerPath(dataPath string) string { + return dataPath + completeMarkerSuffix +} + +// MarkComplete records that the cache entry for key was written in full. +// Only files with a marker are adopted on the next Reload; this is what +// distinguishes a complete cache entry from a partial one left by a crash. +// key may be an original cache key or an already-mapped data path; KeyMapper +// is idempotent for the latter (see KeyMapper). +func (sfs *spreadFS) MarkComplete(key string) error { + f, err := os.OpenFile(sfs.markerPath(sfs.KeyMapper(key)), os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + return f.Close() +} + func (sfs *spreadFS) Remove(name string) error { + if err := os.Remove(sfs.markerPath(name)); err != nil && !os.IsNotExist(err) { + log.Warn("Error removing cache completion marker", "file", name, err) + } return os.Remove(name) } diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index 2768ea2d5..0f88d3a58 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -39,31 +39,93 @@ var _ = Describe("Spread FS", func() { }) }) - Describe("Reload", func() { - var files []string + Describe("MarkComplete / Remove markers", func() { + It("creates a .complete marker for a data file", func() { + data := fs.KeyMapper("song1") + f, err := fs.Create(data) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte("ok")) + _ = f.Close() - BeforeEach(func() { - files = []string{"aaaaa", "bbbbb", "ccccc"} - for _, content := range files { - file := fs.KeyMapper(content) - f, err := fs.Create(file) - Expect(err).To(BeNil()) - _, _ = f.Write([]byte(content)) - _ = f.Close() - } + Expect(fs.MarkComplete(data)).To(Succeed()) + _, statErr := os.Stat(data + ".complete") + Expect(statErr).To(BeNil()) }) - It("loads all files from fs", func() { + It("removes the sibling marker when the data file is removed", func() { + data := fs.KeyMapper("song2") + f, err := fs.Create(data) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte("ok")) + _ = f.Close() + Expect(fs.MarkComplete(data)).To(Succeed()) + + Expect(fs.Remove(data)).To(Succeed()) + _, dataErr := os.Stat(data) + Expect(os.IsNotExist(dataErr)).To(BeTrue()) + _, markErr := os.Stat(data + ".complete") + Expect(os.IsNotExist(markErr)).To(BeTrue()) + }) + }) + + Describe("Reload", func() { + makeData := func(content string) string { + file := fs.KeyMapper(content) + f, err := fs.Create(file) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte(content)) + _ = f.Close() + return file + } + + It("migrates all existing files on first run and writes the sentinel", func() { + for _, c := range []string{"aaaaa", "bbbbb", "ccccc"} { + makeData(c) // no markers, simulating a pre-upgrade cache + } + var actual []string - err := fs.Reload(func(key string, name string) { + err := fs.Reload(func(key, name string) { Expect(key).To(Equal(name)) - data, err := os.ReadFile(name) - Expect(err).To(BeNil()) + data, _ := os.ReadFile(name) actual = append(actual, string(data)) }) Expect(err).To(BeNil()) - Expect(actual).To(HaveLen(len(files))) - Expect(actual).To(ContainElements(files[0], files[1], files[2])) + Expect(actual).To(ContainElements("aaaaa", "bbbbb", "ccccc")) + Expect(actual).To(HaveLen(3)) + + _, sentinelErr := os.Stat(filepath.Join(rootDir, ".nd-migrated")) + Expect(sentinelErr).To(BeNil()) + }) + + It("after migration, adopts only marked files and deletes unmarked partials", func() { + // Pretend migration already happened. + Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) + + good := makeData("good") + Expect(fs.MarkComplete(good)).To(Succeed()) + bad := makeData("bad") // partial: no marker + + var actual []string + err := fs.Reload(func(key, name string) { actual = append(actual, name) }) + Expect(err).To(BeNil()) + Expect(actual).To(ConsistOf(good)) + + _, badErr := os.Stat(bad) + Expect(os.IsNotExist(badErr)).To(BeTrue()) // partial deleted + }) + + It("ignores and cleans orphan markers", func() { + Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) + orphan := fs.KeyMapper("orphan") + ".complete" + Expect(os.MkdirAll(filepath.Dir(orphan), 0755)).To(Succeed()) + Expect(os.WriteFile(orphan, nil, 0600)).To(Succeed()) + + var actual []string + err := fs.Reload(func(key, name string) { actual = append(actual, name) }) + Expect(err).To(BeNil()) + Expect(actual).To(BeEmpty()) + _, orphanErr := os.Stat(orphan) + Expect(os.IsNotExist(orphanErr)).To(BeTrue()) }) }) }) diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go index 7b4786ed5..2a249f455 100644 --- a/utils/chrono/meter.go +++ b/utils/chrono/meter.go @@ -2,8 +2,6 @@ package chrono import ( "time" - - . "github.com/navidrome/navidrome/utils/gg" ) // Meter is a simple stopwatch @@ -13,7 +11,7 @@ type Meter struct { } func (m *Meter) Start() { - m.mark = P(time.Now()) + m.mark = new(time.Now()) } func (m *Meter) Stop() time.Duration { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 208fe2952..837f56339 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -1,11 +1,6 @@ // Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog package gg -// P returns a pointer to the input value -func P[T any](v T) *T { - return &v -} - // V returns the value of the input pointer, or a zero value if the input pointer is nil. func V[T any](p *T) T { if p == nil { @@ -21,3 +16,13 @@ func If[T any](cond bool, v1, v2 T) T { } return v2 } + +// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to +// avoid aliasing the pointed-to value when a separate *T is needed. +func Clone[T any](p *T) *T { + if p == nil { + return nil + } + v := *p + return &v +} diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index 1d6dff484..bb6fae867 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -16,22 +16,9 @@ func TestGG(t *testing.T) { } var _ = Describe("GG", func() { - Describe("P", func() { - It("returns a pointer to the input value", func() { - v := 123 - Expect(gg.P(123)).To(Equal(&v)) - }) - - It("returns nil if the input value is zero", func() { - v := 0 - Expect(gg.P(0)).To(Equal(&v)) - }) - }) - Describe("V", func() { It("returns the value of the input pointer", func() { - v := 123 - Expect(gg.V(&v)).To(Equal(123)) + Expect(gg.V(new(123))).To(Equal(123)) }) It("returns a zero value if the input pointer is nil", func() { @@ -59,4 +46,25 @@ var _ = Describe("GG", func() { Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2)) }) }) + + Describe("Clone", func() { + It("returns a pointer to a copy of the value", func() { + original := 123 + cloned := gg.Clone(&original) + Expect(cloned).To(HaveValue(Equal(123))) + Expect(cloned).NotTo(BeIdenticalTo(&original)) + }) + + It("does not alias the original value", func() { + original := 123 + cloned := gg.Clone(&original) + original = 456 + Expect(*cloned).To(Equal(123)) + }) + + It("returns nil when the input is nil", func() { + var v *int + Expect(gg.Clone(v)).To(BeNil()) + }) + }) }) diff --git a/utils/req/req.go b/utils/req/req.go index 6b6135e1a..2757fc3f5 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -38,8 +38,7 @@ func (r *Values) String(param string) (string, error) { func (r *Values) StringPtr(param string) *string { var v *string if _, exists := r.URL.Query()[param]; exists { - s := r.URL.Query().Get(param) - v = &s + v = new(r.URL.Query().Get(param)) } return v } @@ -48,8 +47,7 @@ func (r *Values) BoolPtr(param string) *bool { var v *bool if _, exists := r.URL.Query()[param]; exists { s := r.URL.Query().Get(param) - b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") - v = &b + v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")) } return v } diff --git a/utils/slice/slice.go b/utils/slice/slice.go index e87ac5388..73537c8f8 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -42,6 +42,16 @@ func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[ return m } +// ToSet builds a set (a map keyed by the slice's elements) for O(1) membership tests. Duplicate +// elements collapse to a single key. +func ToSet[T comparable](s []T) map[T]struct{} { + m := make(map[T]struct{}, len(s)) + for _, item := range s { + m[item] = struct{}{} + } + return m +} + func CompactByFrequency[T comparable](list []T) []T { counters := make(map[T]int) for _, item := range list { diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 65e5f0934..27548d693 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -81,6 +81,20 @@ var _ = Describe("Slice Utils", func() { }) }) + Describe("ToSet", func() { + It("returns empty set for an empty input", func() { + Expect(slice.ToSet([]int{})).To(BeEmpty()) + }) + + It("builds a set with one key per distinct element", func() { + result := slice.ToSet([]int{1, 2, 2, 3, 3, 3}) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKey(1)) + Expect(result).To(HaveKey(2)) + Expect(result).To(HaveKey(3)) + }) + }) + Describe("CompactByFrequency", func() { It("returns empty slice for an empty input", func() { Expect(slice.CompactByFrequency([]int{})).To(BeEmpty()) @@ -134,7 +148,7 @@ var _ = Describe("Slice Utils", func() { count := 0 file, _ := os.Open(path) defer file.Close() - for _ = range slice.LinesFrom(file) { + for range slice.LinesFrom(file) { count++ } Expect(count).To(Equal(expected)) diff --git a/utils/str/normalize_fts.go b/utils/str/normalize_fts.go new file mode 100644 index 000000000..994f77cc9 --- /dev/null +++ b/utils/str/normalize_fts.go @@ -0,0 +1,45 @@ +package str + +import ( + "regexp" + "strings" + + "github.com/deluan/sanitize" +) + +// FTSPunctStrip matches any character that is not a letter or number. Index-time +// normalization (NormalizeForFTS) and query-time processing in persistence share it +// so both sides produce matching tokens. +var FTSPunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`) + +// NormalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of +// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) +// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed +// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — +// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree +// without an explicit transliterated entry here. +func NormalizeForFTS(values ...string) string { + seen := make(map[string]struct{}) + var result []string + add := func(orig, variant string) { + if variant == "" || variant == orig { + return + } + lower := strings.ToLower(variant) + if _, ok := seen[lower]; ok { + return + } + seen[lower] = struct{}{} + result = append(result, variant) + } + for _, v := range values { + for word := range strings.FieldsSeq(v) { + transliterated := sanitize.Accents(word) + // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. + add(word, FTSPunctStrip.ReplaceAllString(transliterated, "")) + // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). + add(word, transliterated) + } + } + return strings.Join(result, " ") +} diff --git a/utils/str/normalize_fts_test.go b/utils/str/normalize_fts_test.go new file mode 100644 index 000000000..52387cb1d --- /dev/null +++ b/utils/str/normalize_fts_test.go @@ -0,0 +1,29 @@ +package str_test + +import ( + "github.com/navidrome/navidrome/utils/str" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("NormalizeForFTS", + func(expected string, values ...string) { + Expect(str.NormalizeForFTS(values...)).To(Equal(expected)) + }, + Entry("strips dots and concatenates", "REM", "R.E.M."), + Entry("strips slash", "ACDC", "AC/DC"), + Entry("strips hyphen", "Aha", "A-ha"), + Entry("skips unchanged ASCII words", "", "The Beatles"), + Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), + Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), + Entry("strips apostrophe from word", "N", "Guns N' Roses"), + Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), + Entry("transliterates ø to o", "Bjork", "Bjørk"), + Entry("transliterates Ø to O", "Oystein", "Øystein"), + Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), + Entry("transliterates Latin diacritics", "cafe", "café"), + Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), + Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), + Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), + Entry("transliterates ß to ss", "Strasse", "Straße"), +)